Merge branch 'main' into diff-overhaul

Andrey Nering created

Change summary

.crush.json                                                |    5 
.github/workflows/lint.yml                                 |   10 
.gitignore                                                 |    4 
.golangci.yml                                              |   44 
README.md                                                  |  104 
Taskfile.yaml                                              |   46 
cmd/root.go                                                |   39 
cmd/schema/README.md                                       |   10 
cmd/schema/main.go                                         |   26 
crush-schema.json                                          |   22 
crush.md                                                   |   22 
cspell.json                                                |    1 
go.mod                                                     |   53 
go.sum                                                     |   86 
install                                                    |   28 
internal/app/app.go                                        |   40 
internal/app/lsp.go                                        |   18 
internal/completions/files-folders.go                      |  191 -
internal/config/config.go                                  |   24 
internal/config/init.go                                    |   33 
internal/db/connect.go                                     |    6 
internal/diff/diff.go                                      |  306 -
internal/exp/diffview/diffview_test.go                     |    2 
internal/fileutil/fileutil.go                              |  114 
internal/fileutil/ls.go                                    |  169 
internal/format/spinner.go                                 |    6 
internal/highlight/highlight.go                            |   60 
internal/history/file.go                                   |    4 
internal/llm/agent/agent-tool.go                           |   10 
internal/llm/agent/agent.go                                |   60 
internal/llm/agent/mcp-tools.go                            |   14 
internal/llm/agent/tools.go                                |   12 
internal/llm/models/local.go                               |   10 
internal/llm/models/models.go                              |   38 
internal/llm/prompt/coder.go                               |   22 
internal/llm/prompt/prompt.go                              |    6 
internal/llm/prompt/prompt_test.go                         |    8 
internal/llm/prompt/summarizer.go                          |    2 
internal/llm/prompt/task.go                                |    4 
internal/llm/prompt/title.go                               |    2 
internal/llm/provider/anthropic.go                         |   15 
internal/llm/provider/azure.go                             |    1 
internal/llm/provider/bedrock.go                           |    5 
internal/llm/provider/gemini.go                            |   10 
internal/llm/provider/openai.go                            |   10 
internal/llm/provider/provider.go                          |   10 
internal/llm/provider/vertexai.go                          |    2 
internal/llm/tools/bash.go                                 |   16 
internal/llm/tools/diagnostics.go                          |    4 
internal/llm/tools/edit.go                                 |   12 
internal/llm/tools/fetch.go                                |    6 
internal/llm/tools/glob.go                                 |    8 
internal/llm/tools/grep.go                                 |    4 
internal/llm/tools/ls.go                                   |  100 
internal/llm/tools/ls_test.go                              |  457 --
internal/llm/tools/patch.go                                |   12 
internal/llm/tools/shell/shell.go                          |   18 
internal/llm/tools/sourcegraph.go                          |    2 
internal/llm/tools/view.go                                 |    4 
internal/llm/tools/write.go                                |   12 
internal/logging/logger.go                                 |    2 
internal/logging/writer.go                                 |    2 
internal/lsp/client.go                                     |    6 
internal/lsp/handlers.go                                   |    8 
internal/lsp/language.go                                   |    2 
internal/lsp/methods.go                                    |    2 
internal/lsp/transport.go                                  |    4 
internal/lsp/util/edit.go                                  |    2 
internal/lsp/watcher/watcher.go                            |    9 
internal/message/content.go                                |    2 
internal/message/message.go                                |    7 
internal/permission/permission.go                          |    8 
internal/session/session.go                                |    4 
internal/tui/components/anim/anim.go                       |  307 +
internal/tui/components/chat/chat.go                       |  568 ++
internal/tui/components/chat/editor.go                     |  318 -
internal/tui/components/chat/editor/editor.go              |  396 ++
internal/tui/components/chat/editor/keys.go                |   59 
internal/tui/components/chat/list.go                       |  487 --
internal/tui/components/chat/message.go                    |  659 ---
internal/tui/components/chat/messages/messages.go          |  289 +
internal/tui/components/chat/messages/renderer.go          |  719 ++++
internal/tui/components/chat/messages/tool.go              |  295 +
internal/tui/components/chat/sidebar.go                    |  378 --
internal/tui/components/chat/sidebar/sidebar.go            |  207 +
internal/tui/components/completions/completions.go         |  195 +
internal/tui/components/completions/item.go                |  281 +
internal/tui/components/completions/keys.go                |   53 
internal/tui/components/core/helpers.go                    |  133 
internal/tui/components/core/list/keys.go                  |   73 
internal/tui/components/core/list/list.go                  | 1360 ++++++++
internal/tui/components/core/status.go                     |  293 -
internal/tui/components/core/status/keys.go                |   55 
internal/tui/components/core/status/status.go              |  113 
internal/tui/components/dialog/arguments.go                |  257 -
internal/tui/components/dialog/commands.go                 |  180 -
internal/tui/components/dialog/complete.go                 |  264 -
internal/tui/components/dialog/custom_commands.go          |  186 -
internal/tui/components/dialog/custom_commands_test.go     |  106 
internal/tui/components/dialog/filepicker.go               |  471 --
internal/tui/components/dialog/help.go                     |  200 -
internal/tui/components/dialog/init.go                     |  189 -
internal/tui/components/dialog/models.go                   |  373 --
internal/tui/components/dialog/permission.go               |  522 ---
internal/tui/components/dialog/quit.go                     |  136 
internal/tui/components/dialog/session.go                  |  230 -
internal/tui/components/dialog/theme.go                    |  198 -
internal/tui/components/dialogs/commands/arguments.go      |  234 +
internal/tui/components/dialogs/commands/commands.go       |  287 +
internal/tui/components/dialogs/commands/item.go           |   57 
internal/tui/components/dialogs/commands/keys.go           |  107 
internal/tui/components/dialogs/commands/loader.go         |  202 +
internal/tui/components/dialogs/compact/compact.go         |  265 +
internal/tui/components/dialogs/compact/keys.go            |   61 
internal/tui/components/dialogs/dialogs.go                 |  162 
internal/tui/components/dialogs/filepicker/filepicker.go   |  229 +
internal/tui/components/dialogs/filepicker/keys.go         |   69 
internal/tui/components/dialogs/init/init.go               |  213 +
internal/tui/components/dialogs/init/keys.go               |   59 
internal/tui/components/dialogs/keys.go                    |   37 
internal/tui/components/dialogs/models/keys.go             |   58 
internal/tui/components/dialogs/models/models.go           |  263 +
internal/tui/components/dialogs/permissions/keys.go        |   70 
internal/tui/components/dialogs/permissions/permissions.go |  473 ++
internal/tui/components/dialogs/quit/keys.go               |   64 
internal/tui/components/dialogs/quit/quit.go               |  125 
internal/tui/components/dialogs/sessions/keys.go           |   58 
internal/tui/components/dialogs/sessions/sessions.go       |  180 +
internal/tui/components/image/image.go                     |   86 
internal/tui/components/image/load.go                      |  146 
internal/tui/components/logo/logo.go                       |  313 +
internal/tui/components/logs/details.go                    |  124 
internal/tui/components/logs/table.go                      |  182 
internal/tui/components/util/simple-list.go                |  159 
internal/tui/image/images.go                               |   72 
internal/tui/keys.go                                       |   56 
internal/tui/layout/container.go                           |  108 
internal/tui/layout/layout.go                              |   13 
internal/tui/layout/overlay.go                             |  169 
internal/tui/layout/split.go                               |  140 
internal/tui/page/chat.go                                  |  237 -
internal/tui/page/chat/chat.go                             |  194 +
internal/tui/page/chat/keys.go                             |   52 
internal/tui/page/logs.go                                  |   83 
internal/tui/page/logs/keys.go                             |   37 
internal/tui/page/logs/logs.go                             |  100 
internal/tui/styles/background.go                          |  123 
internal/tui/styles/chroma.go                              |   79 
internal/tui/styles/crush.go                               |   51 
internal/tui/styles/icons.go                               |    9 
internal/tui/styles/markdown.go                            |  270 -
internal/tui/styles/styles.go                              |  155 
internal/tui/styles/theme.go                               |  630 +++
internal/tui/theme/catppuccin.go                           |  248 -
internal/tui/theme/dracula.go                              |  274 -
internal/tui/theme/flexoki.go                              |  282 -
internal/tui/theme/gruvbox.go                              |  302 -
internal/tui/theme/manager.go                              |  118 
internal/tui/theme/monokai.go                              |  273 -
internal/tui/theme/onedark.go                              |  274 -
internal/tui/theme/opencode.go                             |  277 -
internal/tui/theme/theme.go                                |  208 -
internal/tui/theme/theme_test.go                           |   89 
internal/tui/theme/tokyonight.go                           |  274 -
internal/tui/theme/tron.go                                 |  276 -
internal/tui/tui.go                                        | 1150 +----
internal/tui/util/util.go                                  |    7 
internal/version/version.go                                |    2 
main.go                                                    |   18 
todos.md                                                   |   29 
170 files changed, 11,706 insertions(+), 12,233 deletions(-)

Detailed changes

.opencode.json → .crush.json 🔗

@@ -1,8 +1,11 @@
 {
   "$schema": "./opencode-schema.json",
   "lsp": {
-    "gopls": {
+    "Go": {
       "command": "gopls"
     }
+  },
+  "tui": {
+    "theme": "charm"
   }
 }

.github/workflows/lint.yml 🔗

@@ -0,0 +1,10 @@
+name: lint
+on:
+  push:
+  pull_request:
+
+jobs:
+  lint:
+    uses: charmbracelet/meta/.github/workflows/lint.yml@main
+    with:
+      golangci_path: .golangci.yml

.gitignore 🔗

@@ -41,6 +41,6 @@ Thumbs.db
 .env
 .env.local
 
-.opencode/
+.crush/
 
-opencode
+crush

.golangci.yml 🔗

@@ -0,0 +1,44 @@
+version: "2"
+linters:
+  enable:
+    - bodyclose
+    # - exhaustive
+    # - goconst
+    # - godot
+    # - godox
+    - gomoddirectives
+    - goprintffuncname
+    # - gosec
+    - misspell
+    # - nakedret
+    # - nestif
+    # - nilerr
+    - noctx
+    - nolintlint
+    # - prealloc
+    # - revive
+    - rowserrcheck
+    - sqlclosecheck
+    - tparallel
+    # - unconvert
+    # - unparam
+    - whitespace
+    # - wrapcheck
+  disable:
+    - errcheck
+    - ineffassign
+    - staticcheck
+    - unused
+  exclusions:
+    generated: lax
+    presets:
+      - common-false-positives
+issues:
+  max-issues-per-linter: 0
+  max-same-issues: 0
+formatters:
+  enable:
+    - gofumpt
+    - goimports
+  exclusions:
+    generated: lax

README.md 🔗

@@ -1,4 +1,4 @@
-# ⌬ OpenCode
+# ⌬ Crush
 
 <p align="center"><img src="https://github.com/user-attachments/assets/9ae61ef6-70e5-4876-bc45-5bcb4e52c714" width="800"></p>
 
@@ -8,10 +8,10 @@ A powerful terminal-based AI assistant for developers, providing intelligent cod
 
 ## Overview
 
-OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
+Crush is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
 
 <p>For a quick video overview, check out
-<a href="https://www.youtube.com/watch?v=P8luPmEa1QI"><img width="25" src="https://upload.wikimedia.org/wikipedia/commons/0/09/YouTube_full-color_icon_%282017%29.svg"> OpenCode + Gemini 2.5 Pro: BYE Claude Code! I'm SWITCHING To the FASTEST AI Coder!</a></p>
+<a href="https://www.youtube.com/watch?v=P8luPmEa1QI"><img width="25" src="https://upload.wikimedia.org/wikipedia/commons/0/09/YouTube_full-color_icon_%282017%29.svg"> Crush + Gemini 2.5 Pro: BYE Claude Code! I'm SWITCHING To the FASTEST AI Coder!</a></p>
 
 <a href="https://www.youtube.com/watch?v=P8luPmEa1QI"><img width="550" src="https://i3.ytimg.com/vi/P8luPmEa1QI/maxresdefault.jpg"></a><p>
 
@@ -34,45 +34,45 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
 
 ```bash
 # Install the latest version
-curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bash
+curl -fsSL https://raw.githubusercontent.com/charmbracelet/crush/refs/heads/main/install | bash
 
 # Install a specific version
-curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | VERSION=0.1.0 bash
+curl -fsSL https://raw.githubusercontent.com/charmbracelet/crush/refs/heads/main/install | VERSION=0.1.0 bash
 ```
 
 ### Using Homebrew (macOS and Linux)
 
 ```bash
-brew install opencode-ai/tap/opencode
+brew install crush-ai/tap/crush
 ```
 
 ### Using AUR (Arch Linux)
 
 ```bash
 # Using yay
-yay -S opencode-ai-bin
+yay -S crush-ai-bin
 
 # Using paru
-paru -S opencode-ai-bin
+paru -S crush-ai-bin
 ```
 
 ### Using Go
 
 ```bash
-go install github.com/opencode-ai/opencode@latest
+go install github.com/charmbracelet/crush@latest
 ```
 
 ## Configuration
 
-OpenCode looks for configuration in the following locations:
+Crush looks for configuration in the following locations:
 
-- `$HOME/.opencode.json`
-- `$XDG_CONFIG_HOME/opencode/.opencode.json`
-- `./.opencode.json` (local directory)
+- `$HOME/.crush.json`
+- `$XDG_CONFIG_HOME/crush/.crush.json`
+- `./.crush.json` (local directory)
 
 ### Auto Compact Feature
 
-OpenCode includes an auto compact feature that automatically summarizes your conversation when it approaches the model's context window limit. When enabled (default setting), this feature:
+Crush includes an auto compact feature that automatically summarizes your conversation when it approaches the model's context window limit. When enabled (default setting), this feature:
 
 - Monitors token usage during your conversation
 - Automatically triggers summarization when usage reaches 95% of the model's context window
@@ -89,7 +89,7 @@ You can enable or disable this feature in your configuration file:
 
 ### Environment Variables
 
-You can configure OpenCode using environment variables:
+You can configure Crush using environment variables:
 
 | Environment Variable       | Purpose                                                |
 | -------------------------- | ------------------------------------------------------ |
@@ -110,7 +110,7 @@ You can configure OpenCode using environment variables:
 
 ### Shell Configuration
 
-OpenCode allows you to configure the shell used by the bash tool. By default, it uses the shell specified in the `SHELL` environment variable, or falls back to `/bin/bash` if not set.
+Crush allows you to configure the shell used by the bash tool. By default, it uses the shell specified in the `SHELL` environment variable, or falls back to `/bin/bash` if not set.
 
 You can override this in your configuration file:
 
@@ -130,7 +130,7 @@ This is useful if you want to use a different shell than your default system she
 ```json
 {
   "data": {
-    "directory": ".opencode"
+    "directory": ".crush"
   },
   "providers": {
     "openai": {
@@ -190,7 +190,7 @@ This is useful if you want to use a different shell than your default system she
 
 ## Supported AI Models
 
-OpenCode supports a variety of AI models from different providers:
+Crush supports a variety of AI models from different providers:
 
 ### OpenAI
 
@@ -247,38 +247,38 @@ OpenCode supports a variety of AI models from different providers:
 ## Usage
 
 ```bash
-# Start OpenCode
-opencode
+# Start Crush
+crush
 
 # Start with debug logging
-opencode -d
+crush -d
 
 # Start with a specific working directory
-opencode -c /path/to/project
+crush -c /path/to/project
 ```
 
 ## Non-interactive Prompt Mode
 
-You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
+You can run Crush in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
 
 ```bash
 # Run a single prompt and print the AI's response to the terminal
-opencode -p "Explain the use of context in Go"
+crush -p "Explain the use of context in Go"
 
 # Get response in JSON format
-opencode -p "Explain the use of context in Go" -f json
+crush -p "Explain the use of context in Go" -f json
 
 # Run without showing the spinner (useful for scripts)
-opencode -p "Explain the use of context in Go" -q
+crush -p "Explain the use of context in Go" -q
 ```
 
-In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
+In this mode, Crush will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
 
-By default, a spinner animation is displayed while the model is processing your query. You can disable this spinner with the `-q` or `--quiet` flag, which is particularly useful when running OpenCode from scripts or automated workflows.
+By default, a spinner animation is displayed while the model is processing your query. You can disable this spinner with the `-q` or `--quiet` flag, which is particularly useful when running Crush from scripts or automated workflows.
 
 ### Output Formats
 
-OpenCode supports the following output formats in non-interactive mode:
+Crush supports the following output formats in non-interactive mode:
 
 | Format | Description                     |
 | ------ | ------------------------------- |
@@ -369,7 +369,7 @@ The output format is implemented as a strongly-typed `OutputFormat` in the codeb
 
 ## AI Assistant Tools
 
-OpenCode's AI assistant has access to various tools to help with coding tasks:
+Crush's AI assistant has access to various tools to help with coding tasks:
 
 ### File and Code Tools
 
@@ -395,7 +395,7 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
 
 ## Architecture
 
-OpenCode is built with a modular architecture:
+Crush is built with a modular architecture:
 
 - **cmd**: Command-line interface using Cobra
 - **internal/app**: Core application services
@@ -410,7 +410,7 @@ OpenCode is built with a modular architecture:
 
 ## Custom Commands
 
-OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
+Crush supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
 
 ### Creating Custom Commands
 
@@ -419,26 +419,26 @@ Custom commands are predefined prompts stored as Markdown files in one of three
 1. **User Commands** (prefixed with `user:`):
 
    ```
-   $XDG_CONFIG_HOME/opencode/commands/
+   $XDG_CONFIG_HOME/crush/commands/
    ```
 
-   (typically `~/.config/opencode/commands/` on Linux/macOS)
+   (typically `~/.config/crush/commands/` on Linux/macOS)
 
    or
 
    ```
-   $HOME/.opencode/commands/
+   $HOME/.crush/commands/
    ```
 
 2. **Project Commands** (prefixed with `project:`):
 
    ```
-   <PROJECT DIR>/.opencode/commands/
+   <PROJECT DIR>/.crush/commands/
    ```
 
 Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
 
-For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
+For example, creating a file at `~/.config/crush/commands/prime-context.md` with content:
 
 ```markdown
 RUN git ls-files
@@ -449,7 +449,7 @@ This creates a command called `user:prime-context`.
 
 ### Command Arguments
 
-OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
+Crush supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
 
 For example:
 
@@ -461,7 +461,7 @@ RUN git grep --author="$AUTHOR_NAME" -n .
 RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
 ```
 
-When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
+When you run a command with arguments, Crush will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
 
 - Clear identification of what each argument represents
 - Ability to use the same argument multiple times
@@ -472,7 +472,7 @@ When you run a command with arguments, OpenCode will prompt you to enter values
 You can organize commands in subdirectories:
 
 ```
-~/.config/opencode/commands/git/commit.md
+~/.config/crush/commands/git/commit.md
 ```
 
 This creates a command with ID `user:git:commit`.
@@ -487,16 +487,16 @@ The content of the command file will be sent as a message to the AI assistant.
 
 ### Built-in Commands
 
-OpenCode includes several built-in commands:
+Crush includes several built-in commands:
 
 | Command            | Description                                                                                         |
 | ------------------ | --------------------------------------------------------------------------------------------------- |
-| Initialize Project | Creates or updates the OpenCode.md memory file with project-specific information                    |
+| Initialize Project | Creates or updates the Crush.md memory file with project-specific information                    |
 | Compact Session    | Manually triggers the summarization of the current session, creating a new session with the summary |
 
 ## MCP (Model Context Protocol)
 
-OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
+Crush implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
 
 ### MCP Features
 
@@ -537,7 +537,7 @@ Once configured, MCP tools are automatically available to the AI assistant along
 
 ## LSP (Language Server Protocol)
 
-OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
+Crush integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
 
 ### LSP Features
 
@@ -576,13 +576,13 @@ While the LSP client implementation supports the full LSP protocol (including co
 
 ## Using a self-hosted model provider
 
-OpenCode can also load and use models from a self-hosted (OpenAI-like) provider.
+Crush can also load and use models from a self-hosted (OpenAI-like) provider.
 This is useful for developers who want to experiment with custom models.
 
 ### Configuring a self-hosted provider
 
 You can use a self-hosted model by setting the `LOCAL_ENDPOINT` environment variable.
-This will cause OpenCode to load and use the models from the specified endpoint.
+This will cause Crush to load and use the models from the specified endpoint.
 
 ```bash
 LOCAL_ENDPOINT=http://localhost:1235/v1
@@ -613,19 +613,19 @@ You can also configure a self-hosted model in the configuration file under the `
 
 ```bash
 # Clone the repository
-git clone https://github.com/opencode-ai/opencode.git
-cd opencode
+git clone https://github.com/charmbracelet/crush.git
+cd crush
 
 # Build
-go build -o opencode
+go build -o crush
 
 # Run
-./opencode
+./crush
 ```
 
 ## Acknowledgments
 
-OpenCode gratefully acknowledges the contributions and support from these key individuals:
+Crush gratefully acknowledges the contributions and support from these key individuals:
 
 - [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation
 - [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
@@ -634,7 +634,7 @@ Special thanks to the broader open source community whose tools and libraries ha
 
 ## License
 
-OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
+Crush is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
 
 ## Contributing
 

Taskfile.yaml 🔗

@@ -0,0 +1,46 @@
+# https://taskfile.dev
+
+version: "3"
+
+tasks:
+  lint:
+    desc: Run base linters
+    cmds:
+      - golangci-lint run
+
+  lint-fix:
+    desc: Run base linters and fix issues
+    cmds:
+      - golangci-lint run --fix
+
+  test:
+    desc: Run tests
+    cmds:
+      - go test ./... {{.CLI_ARGS}}
+
+  fmt:
+    desc: Run gofumpt
+    cmds:
+      - gofumpt -w .
+
+  dev:
+    desc: Run with profiling enabled
+    env:
+      CRUSH_PROFILE: true
+    cmds:
+      - go run .
+
+  profile:cpu:
+    desc: 10s CPU profile
+    cmds:
+      - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/profile?seconds=10'
+
+  profile:heap:
+    desc: Heap profile
+    cmds:
+      - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/heap'
+
+  profile:allocs:
+    desc: Allocations profile
+    cmds:
+      - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/allocs'

cmd/root.go 🔗

@@ -7,44 +7,43 @@ import (
 	"sync"
 	"time"
 
-	tea "github.com/charmbracelet/bubbletea"
-	zone "github.com/lrstanley/bubblezone"
-	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/db"
-	"github.com/opencode-ai/opencode/internal/format"
-	"github.com/opencode-ai/opencode/internal/llm/agent"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/pubsub"
-	"github.com/opencode-ai/opencode/internal/tui"
-	"github.com/opencode-ai/opencode/internal/version"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/format"
+	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/tui"
+	"github.com/charmbracelet/crush/internal/version"
 	"github.com/spf13/cobra"
 )
 
 var rootCmd = &cobra.Command{
-	Use:   "opencode",
+	Use:   "crush",
 	Short: "Terminal-based AI assistant for software development",
-	Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks.
+	Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
 It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
 to assist developers in writing, debugging, and understanding code directly from the terminal.`,
 	Example: `
   # Run in interactive mode
-  opencode
+  crush
 
   # Run with debug logging
-  opencode -d
+  crush -d
 
   # Run with debug logging in a specific directory
-  opencode -d -c /path/to/project
+  crush -d -c /path/to/project
 
   # Print version
-  opencode -v
+  crush -v
 
   # Run a single non-interactive prompt
-  opencode -p "Explain the use of context in Go"
+  crush -p "Explain the use of context in Go"
 
   # Run a single non-interactive prompt with JSON output format
-  opencode -p "Explain the use of context in Go" -f json
+  crush -p "Explain the use of context in Go" -f json
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
 		// If the help flag is set, show the help message
@@ -114,9 +113,7 @@ to assist developers in writing, debugging, and understanding code directly from
 			return app.RunNonInteractive(ctx, prompt, outputFormat, quiet)
 		}
 
-		// Interactive mode
 		// Set up the TUI
-		zone.NewGlobal()
 		program := tea.NewProgram(
 			tui.New(app),
 			tea.WithAltScreen(),

cmd/schema/README.md 🔗

@@ -1,11 +1,11 @@
-# OpenCode Configuration Schema Generator
+# Crush Configuration Schema Generator
 
-This tool generates a JSON Schema for the OpenCode configuration file. The schema can be used to validate configuration files and provide autocompletion in editors that support JSON Schema.
+This tool generates a JSON Schema for the Crush configuration file. The schema can be used to validate configuration files and provide autocompletion in editors that support JSON Schema.
 
 ## Usage
 
 ```bash
-go run cmd/schema/main.go > opencode-schema.json
+go run cmd/schema/main.go > crush-schema.json
 ```
 
 This will generate a JSON Schema file that can be used to validate configuration files.
@@ -24,7 +24,7 @@ The generated schema includes:
 
 You can use the generated schema in several ways:
 
-1. **Editor Integration**: Many editors (VS Code, JetBrains IDEs, etc.) support JSON Schema for validation and autocompletion. You can configure your editor to use the generated schema for `.opencode.json` files.
+1. **Editor Integration**: Many editors (VS Code, JetBrains IDEs, etc.) support JSON Schema for validation and autocompletion. You can configure your editor to use the generated schema for `.crush.json` files.
 
 2. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema.
 
@@ -37,7 +37,7 @@ Here's an example configuration that conforms to the schema:
 ```json
 {
   "data": {
-    "directory": ".opencode"
+    "directory": ".crush"
   },
   "debug": false,
   "providers": {

cmd/schema/main.go 🔗

@@ -5,8 +5,8 @@ import (
 	"fmt"
 	"os"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
 )
 
 // JSONSchemaType represents a JSON Schema type
@@ -38,8 +38,8 @@ func main() {
 func generateSchema() map[string]any {
 	schema := map[string]any{
 		"$schema":     "http://json-schema.org/draft-07/schema#",
-		"title":       "OpenCode Configuration",
-		"description": "Configuration schema for the OpenCode application",
+		"title":       "Crush Configuration",
+		"description": "Configuration schema for the Crush application",
 		"type":        "object",
 		"properties":  map[string]any{},
 	}
@@ -52,7 +52,7 @@ func generateSchema() map[string]any {
 			"directory": map[string]any{
 				"type":        "string",
 				"description": "Directory where application data is stored",
-				"default":     ".opencode",
+				"default":     ".crush",
 			},
 		},
 		"required": []string{"directory"},
@@ -89,12 +89,12 @@ func generateSchema() map[string]any {
 			".cursor/rules/",
 			"CLAUDE.md",
 			"CLAUDE.local.md",
-			"opencode.md",
-			"opencode.local.md",
-			"OpenCode.md",
-			"OpenCode.local.md",
-			"OPENCODE.md",
-			"OPENCODE.local.md",
+			"crush.md",
+			"crush.local.md",
+			"Crush.md",
+			"Crush.local.md",
+			"CRUSH.md",
+			"CRUSH.local.md",
 		},
 	}
 
@@ -105,9 +105,9 @@ func generateSchema() map[string]any {
 			"theme": map[string]any{
 				"type":        "string",
 				"description": "TUI theme name",
-				"default":     "opencode",
+				"default":     "crush",
 				"enum": []string{
-					"opencode",
+					"crush",
 					"catppuccin",
 					"dracula",
 					"flexoki",

opencode-schema.json → crush-schema.json 🔗

@@ -97,7 +97,7 @@
       "type": "object"
     }
   },
-  "description": "Configuration schema for the OpenCode application",
+  "description": "Configuration schema for the Crush application",
   "properties": {
     "agents": {
       "additionalProperties": {
@@ -216,12 +216,12 @@
         ".cursor/rules/",
         "CLAUDE.md",
         "CLAUDE.local.md",
-        "opencode.md",
-        "opencode.local.md",
-        "OpenCode.md",
-        "OpenCode.local.md",
-        "OPENCODE.md",
-        "OPENCODE.local.md"
+        "crush.md",
+        "crush.local.md",
+        "Crush.md",
+        "Crush.local.md",
+        "CRUSH.md",
+        "CRUSH.local.md"
       ],
       "description": "Context paths for the application",
       "items": {
@@ -233,7 +233,7 @@
       "description": "Storage configuration",
       "properties": {
         "directory": {
-          "default": ".opencode",
+          "default": ".crush",
           "description": "Directory where application data is stored",
           "type": "string"
         }
@@ -374,10 +374,10 @@
       "description": "Terminal User Interface configuration",
       "properties": {
         "theme": {
-          "default": "opencode",
+          "default": "crush",
           "description": "TUI theme name",
           "enum": [
-            "opencode",
+            "crush",
             "catppuccin",
             "dracula",
             "flexoki",
@@ -397,6 +397,6 @@
       "type": "string"
     }
   },
-  "title": "OpenCode Configuration",
+  "title": "Crush Configuration",
   "type": "object"
 }

crush.md 🔗

@@ -0,0 +1,22 @@
+# Crush Development Guide
+
+## Build/Test/Lint Commands
+- **Build**: `go build .` or `go run .`
+- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
+- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
+- **Format**: `task fmt` (gofumpt -w .)
+- **Dev**: `task dev` (runs with profiling enabled)
+
+## Code Style Guidelines
+- **Imports**: Use goimports formatting, group stdlib, external, internal packages
+- **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint
+- **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported
+- **Types**: Prefer explicit types, use type aliases for clarity (e.g., `type AgentName string`)
+- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
+- **Context**: Always pass context.Context as first parameter for operations
+- **Interfaces**: Define interfaces in consuming packages, keep them small and focused
+- **Structs**: Use struct embedding for composition, group related fields
+- **Constants**: Use typed constants with iota for enums, group in const blocks
+- **Testing**: Use testify/assert and testify/require, parallel tests with `t.Parallel()`
+- **JSON tags**: Use snake_case for JSON field names
+- **File permissions**: Use octal notation (0o755, 0o644) for file permissions

cspell.json 🔗

@@ -0,0 +1 @@
+{"flagWords":[],"language":"en","words":["crush","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph","filepicker","imageorient","rasterx","oksvg","termenv","trashhalo","lucasb","nfnt","srwiley","Lanczos"],"version":"0.2"}

go.mod 🔗

@@ -1,42 +1,51 @@
-module github.com/opencode-ai/opencode
+module github.com/charmbracelet/crush
 
 go 1.24.0
 
 require (
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
+	github.com/MakeNowJust/heredoc v1.0.0
 	github.com/PuerkitoBio/goquery v1.9.2
 	github.com/alecthomas/chroma/v2 v2.15.0
 	github.com/anthropics/anthropic-sdk-go v1.4.0
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.8.1
-	github.com/catppuccin/go v0.3.0
-	github.com/charmbracelet/bubbles v0.21.0
-	github.com/charmbracelet/bubbletea v1.3.5
-	github.com/charmbracelet/glamour v0.9.1
-	github.com/charmbracelet/lipgloss v1.1.0
-	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1
-	github.com/charmbracelet/x/ansi v0.8.0
-	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444
-	github.com/charmbracelet/x/exp/golden v0.0.0-20250602192518-9e722df69bbb
-	github.com/charmbracelet/x/exp/slice v0.0.0-20250606192012-6931dbeeced5
+	github.com/charlievieth/fastwalk v1.0.11
+	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e
+	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94
+	github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
+	github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
+	github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
+	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
+	github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
+	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
 	github.com/fsnotify/fsnotify v1.8.0
 	github.com/go-logfmt/logfmt v0.6.0
 	github.com/google/uuid v1.6.0
-	github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
 	github.com/mark3labs/mcp-go v0.17.0
-	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
-	github.com/muesli/reflow v0.3.0
 	github.com/muesli/termenv v0.16.0
 	github.com/ncruces/go-sqlite3 v0.25.0
+	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
 	github.com/openai/openai-go v0.1.0-beta.2
 	github.com/pressly/goose/v3 v3.24.2
+	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
+	github.com/sahilm/fuzzy v0.1.1
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
 	github.com/spf13/cobra v1.9.1
 	github.com/spf13/viper v1.20.0
+	github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
+	github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
 	github.com/stretchr/testify v1.10.0
 )
 
+require (
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/disintegration/gift v1.1.2 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+)
+
 require (
 	cloud.google.com/go v0.116.0 // indirect
 	cloud.google.com/go/auth v0.13.0 // indirect
@@ -60,15 +69,15 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
 	github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
 	github.com/aws/smithy-go v1.20.3 // indirect
-	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
-	github.com/charmbracelet/colorprofile v0.3.0 // indirect
-	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+	github.com/charmbracelet/colorprofile v0.3.1 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
+	github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
+	github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect
 	github.com/charmbracelet/x/term v0.2.1 // indirect
+	github.com/charmbracelet/x/windows v0.2.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/disintegration/imaging v1.6.2
 	github.com/dlclark/regexp2 v1.11.4 // indirect
-	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/go-logr/logr v1.4.2 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
@@ -82,10 +91,7 @@ require (
 	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/kylelemons/godebug v1.1.0 // indirect
-	github.com/lithammer/fuzzysearch v1.1.8
 	github.com/lucasb-eyer/go-colorful v1.2.0
-	github.com/mattn/go-isatty v0.0.20 // indirect
-	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.16 // indirect
 	github.com/mfridman/interpolate v0.0.2 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
@@ -94,7 +100,7 @@ require (
 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/rivo/uniseg v0.4.7
 	github.com/rogpeppe/go-internal v1.14.1 // indirect
 	github.com/sagikazarmark/locafero v0.7.0 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -123,7 +129,6 @@ require (
 	golang.org/x/net v0.39.0 // indirect
 	golang.org/x/sync v0.13.0 // indirect
 	golang.org/x/sys v0.32.0 // indirect
-	golang.org/x/term v0.31.0 // indirect
 	golang.org/x/text v0.24.0 // indirect
 	google.golang.org/genai v1.3.0
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect

go.sum 🔗

@@ -66,44 +66,46 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
 github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
-github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
-github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
-github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
-github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
-github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
-github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
-github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
-github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
-github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
-github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
-github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
-github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
-github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
-github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
-github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
-github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
-github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
-github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250602192518-9e722df69bbb h1:GT/STWThMsrOfYQnhnIPb165e/g1waAp0gNMFvEO6WI=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250602192518-9e722df69bbb/go.mod h1:929X+xY3LeoOZrDWIBVZcx/zyS0CYtyLiUIvE4VbKC0=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250606192012-6931dbeeced5 h1:NrUNqSKeW0nKSccnzgJYxC+SFGx+C7wskfASBKToXBs=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250606192012-6931dbeeced5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
+github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
+github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94 h1:QIi50k+uNTJmp2sMs+33D1m/EWr/7OPTJ8x92AY3eOc=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
+github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
+github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
+github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
+github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
+github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ=
+github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9/go.mod h1:vr+xCFylsPYq2qSz+n5/jItjcK2/PgrKFMTI7VRR6CI=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef h1:v7qwsZ2OxzlwvpKwz8dtZXp7fIJlcDEUOyFBNE4fz4Q=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y=
+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
+github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
-github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
+github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
+github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
+github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
 github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
 github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
-github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -150,31 +152,20 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
-github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
-github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
-github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
 github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
-github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
-github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
 github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
-github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
-github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
@@ -183,6 +174,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
 github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
 github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
 github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
 github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
@@ -196,15 +189,18 @@ github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJz
 github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
 github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
 github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@@ -225,9 +221,14 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
 github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -279,7 +280,6 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
 golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
-golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
 golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -303,7 +303,6 @@ golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -324,8 +323,6 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
-golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
-golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -355,6 +352,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=

install 🔗

@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 set -euo pipefail
-APP=opencode
+APP=crush
 
 RED='\033[0;31m'
 GREEN='\033[0;32m'
@@ -36,19 +36,19 @@ case "$filename" in
     ;;
 esac
 
-INSTALL_DIR=$HOME/.opencode/bin
+INSTALL_DIR=$HOME/.crush/bin
 mkdir -p "$INSTALL_DIR"
 
 if [ -z "$requested_version" ]; then
-    url="https://github.com/opencode-ai/opencode/releases/latest/download/$filename"
-    specific_version=$(curl -s https://api.github.com/repos/opencode-ai/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
+    url="https://github.com/charmbracelet/crush/releases/latest/download/$filename"
+    specific_version=$(curl -s https://api.github.com/repos/charmbracelet/crush/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
 
     if [[ $? -ne 0 ]]; then
         echo "${RED}Failed to fetch version information${NC}"
         exit 1
     fi
 else
-    url="https://github.com/opencode-ai/opencode/releases/download/v${requested_version}/$filename"
+    url="https://github.com/charmbracelet/crush/releases/download/v${requested_version}/$filename"
     specific_version=$requested_version
 fi
 
@@ -67,12 +67,12 @@ print_message() {
 }
 
 check_version() {
-    if command -v opencode >/dev/null 2>&1; then
-        opencode_path=$(which opencode)
+    if command -v crush >/dev/null 2>&1; then
+        crush_path=$(which crush)
 
 
         ## TODO: check if version is installed
-        # installed_version=$(opencode version)
+        # installed_version=$(crush version)
         installed_version="0.0.1"
         installed_version=$(echo $installed_version | awk '{print $2}')
 
@@ -86,11 +86,11 @@ check_version() {
 }
 
 download_and_install() {
-    print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
-    mkdir -p opencodetmp && cd opencodetmp
+    print_message info "Downloading ${ORANGE}crush ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
+    mkdir -p crushtmp && cd crushtmp
     curl -# -L $url | tar xz
-    mv opencode $INSTALL_DIR
-    cd .. && rm -rf opencodetmp 
+    mv crush $INSTALL_DIR
+    cd .. && rm -rf crushtmp 
 }
 
 check_version
@@ -102,9 +102,9 @@ add_to_path() {
     local command=$2
 
     if [[ -w $config_file ]]; then
-        echo -e "\n# opencode" >> "$config_file"
+        echo -e "\n# crush" >> "$config_file"
         echo "$command" >> "$config_file"
-        print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
+        print_message info "Successfully added ${ORANGE}crush ${GREEN}to \$PATH in $config_file"
     else
         print_message warning "Manually add the directory to $config_file (or similar):"
         print_message info "  $command"

internal/app/app.go 🔗

@@ -9,17 +9,16 @@ import (
 	"sync"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/db"
-	"github.com/opencode-ai/opencode/internal/format"
-	"github.com/opencode-ai/opencode/internal/history"
-	"github.com/opencode-ai/opencode/internal/llm/agent"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/permission"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/format"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
 )
 
 type App struct {
@@ -53,9 +52,6 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
 		LSPClients:  make(map[string]*lsp.Client),
 	}
 
-	// Initialize theme based on configuration
-	app.initTheme()
-
 	// Initialize LSP clients in the background
 	go app.initLSPClients(ctx)
 
@@ -80,22 +76,6 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
 	return app, nil
 }
 
-// initTheme sets the application theme based on the configuration
-func (app *App) initTheme() {
-	cfg := config.Get()
-	if cfg == nil || cfg.TUI.Theme == "" {
-		return // Use default theme
-	}
-
-	// Try to set the theme from config
-	err := theme.SetTheme(cfg.TUI.Theme)
-	if err != nil {
-		logging.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err)
-	} else {
-		logging.Debug("Set theme from config", "theme", cfg.TUI.Theme)
-	}
-}
-
 // RunNonInteractive handles the execution flow when a prompt is provided via CLI flag.
 func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat string, quiet bool) error {
 	logging.Info("Running in non-interactive mode")

internal/app/lsp.go 🔗

@@ -4,10 +4,10 @@ import (
 	"context"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/lsp/watcher"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/lsp/watcher"
 )
 
 func (app *App) initLSPClients(ctx context.Context) {
@@ -25,7 +25,7 @@ func (app *App) initLSPClients(ctx context.Context) {
 func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
 	// Create a specific context for initialization with a timeout
 	logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
-	
+
 	// Create the LSP client
 	lspClient, err := lsp.NewClient(ctx, command, args...)
 	if err != nil {
@@ -36,7 +36,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
 	// Create a longer timeout for initialization (some servers take time to start)
 	initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
 	defer cancel()
-	
+
 	// Initialize with the initialization context
 	_, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
 	if err != nil {
@@ -57,13 +57,13 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
 	}
 
 	logging.Info("LSP client initialized", "name", name)
-	
+
 	// Create a child context that can be canceled when the app is shutting down
 	watchCtx, cancelFunc := context.WithCancel(ctx)
-	
+
 	// Create a context with the server name for better identification
 	watchCtx = context.WithValue(watchCtx, "serverName", name)
-	
+
 	// Create the workspace watcher
 	workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
 

internal/completions/files-folders.go 🔗

@@ -1,191 +0,0 @@
-package completions
-
-import (
-	"bytes"
-	"fmt"
-	"os/exec"
-	"path/filepath"
-
-	"github.com/lithammer/fuzzysearch/fuzzy"
-	"github.com/opencode-ai/opencode/internal/fileutil"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
-)
-
-type filesAndFoldersContextGroup struct {
-	prefix string
-}
-
-func (cg *filesAndFoldersContextGroup) GetId() string {
-	return cg.prefix
-}
-
-func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
-	return dialog.NewCompletionItem(dialog.CompletionItem{
-		Title: "Files & Folders",
-		Value: "files",
-	})
-}
-
-func processNullTerminatedOutput(outputBytes []byte) []string {
-	if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
-		outputBytes = outputBytes[:len(outputBytes)-1]
-	}
-
-	if len(outputBytes) == 0 {
-		return []string{}
-	}
-
-	split := bytes.Split(outputBytes, []byte{0})
-	matches := make([]string, 0, len(split))
-
-	for _, p := range split {
-		if len(p) == 0 {
-			continue
-		}
-
-		path := string(p)
-		path = filepath.Join(".", path)
-
-		if !fileutil.SkipHidden(path) {
-			matches = append(matches, path)
-		}
-	}
-
-	return matches
-}
-
-func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
-	cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
-	cmdFzf := fileutil.GetFzfCmd(query)
-
-	var matches []string
-	// Case 1: Both rg and fzf available
-	if cmdRg != nil && cmdFzf != nil {
-		rgPipe, err := cmdRg.StdoutPipe()
-		if err != nil {
-			return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
-		}
-		defer rgPipe.Close()
-
-		cmdFzf.Stdin = rgPipe
-		var fzfOut bytes.Buffer
-		var fzfErr bytes.Buffer
-		cmdFzf.Stdout = &fzfOut
-		cmdFzf.Stderr = &fzfErr
-
-		if err := cmdFzf.Start(); err != nil {
-			return nil, fmt.Errorf("failed to start fzf: %w", err)
-		}
-
-		errRg := cmdRg.Run()
-		errFzf := cmdFzf.Wait()
-
-		if errRg != nil {
-			logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
-		}
-
-		if errFzf != nil {
-			if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
-				return []string{}, nil // No matches from fzf
-			}
-			return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
-		}
-
-		matches = processNullTerminatedOutput(fzfOut.Bytes())
-
-		// Case 2: Only rg available
-	} else if cmdRg != nil {
-		logging.Debug("Using Ripgrep with fuzzy match fallback for file completions")
-		var rgOut bytes.Buffer
-		var rgErr bytes.Buffer
-		cmdRg.Stdout = &rgOut
-		cmdRg.Stderr = &rgErr
-
-		if err := cmdRg.Run(); err != nil {
-			return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
-		}
-
-		allFiles := processNullTerminatedOutput(rgOut.Bytes())
-		matches = fuzzy.Find(query, allFiles)
-
-		// Case 3: Only fzf available
-	} else if cmdFzf != nil {
-		logging.Debug("Using FZF with doublestar fallback for file completions")
-		files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
-		if err != nil {
-			return nil, fmt.Errorf("failed to list files for fzf: %w", err)
-		}
-
-		allFiles := make([]string, 0, len(files))
-		for _, file := range files {
-			if !fileutil.SkipHidden(file) {
-				allFiles = append(allFiles, file)
-			}
-		}
-
-		var fzfIn bytes.Buffer
-		for _, file := range allFiles {
-			fzfIn.WriteString(file)
-			fzfIn.WriteByte(0)
-		}
-
-		cmdFzf.Stdin = &fzfIn
-		var fzfOut bytes.Buffer
-		var fzfErr bytes.Buffer
-		cmdFzf.Stdout = &fzfOut
-		cmdFzf.Stderr = &fzfErr
-
-		if err := cmdFzf.Run(); err != nil {
-			if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
-				return []string{}, nil
-			}
-			return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
-		}
-
-		matches = processNullTerminatedOutput(fzfOut.Bytes())
-
-		// Case 4: Fallback to doublestar with fuzzy match
-	} else {
-		logging.Debug("Using doublestar with fuzzy match for file completions")
-		allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
-		if err != nil {
-			return nil, fmt.Errorf("failed to glob files: %w", err)
-		}
-
-		filteredFiles := make([]string, 0, len(allFiles))
-		for _, file := range allFiles {
-			if !fileutil.SkipHidden(file) {
-				filteredFiles = append(filteredFiles, file)
-			}
-		}
-
-		matches = fuzzy.Find(query, filteredFiles)
-	}
-
-	return matches, nil
-}
-
-func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
-	matches, err := cg.getFiles(query)
-	if err != nil {
-		return nil, err
-	}
-
-	items := make([]dialog.CompletionItemI, 0, len(matches))
-	for _, file := range matches {
-		item := dialog.NewCompletionItem(dialog.CompletionItem{
-			Title: file,
-			Value: file,
-		})
-		items = append(items, item)
-	}
-
-	return items, nil
-}
-
-func NewFileAndFolderContextGroup() dialog.CompletionProvider {
-	return &filesAndFoldersContextGroup{
-		prefix: "file",
-	}
-}

internal/config/config.go 🔗

@@ -9,8 +9,8 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/logging"
 	"github.com/spf13/viper"
 )
 
@@ -97,9 +97,9 @@ type Config struct {
 
 // Application constants
 const (
-	defaultDataDirectory = ".opencode"
+	defaultDataDirectory = ".crush"
 	defaultLogLevel      = "info"
-	appName              = "opencode"
+	appName              = "crush"
 
 	MaxTokensFallbackDefault = 4096
 )
@@ -110,12 +110,12 @@ var defaultContextPaths = []string{
 	".cursor/rules/",
 	"CLAUDE.md",
 	"CLAUDE.local.md",
-	"opencode.md",
-	"opencode.local.md",
-	"OpenCode.md",
-	"OpenCode.local.md",
-	"OPENCODE.md",
-	"OPENCODE.local.md",
+	"crush.md",
+	"crush.local.md",
+	"Crush.md",
+	"Crush.local.md",
+	"CRUSH.md",
+	"CRUSH.local.md",
 }
 
 // Global configuration instance
@@ -159,7 +159,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
 	if cfg.Debug {
 		defaultLevel = slog.LevelDebug
 	}
-	if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
+	if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
 		loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
 
 		// if file does not exist create it
@@ -221,7 +221,7 @@ func configureViper() {
 func setDefaults(debug bool) {
 	viper.SetDefault("data.directory", defaultDataDirectory)
 	viper.SetDefault("contextPaths", defaultContextPaths)
-	viper.SetDefault("tui.theme", "opencode")
+	viper.SetDefault("tui.theme", "crush")
 	viper.SetDefault("autoCompact", true)
 
 	// Set default shell from environment or fallback to /bin/bash

internal/config/init.go 🔗

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 )
 
 const (
@@ -37,10 +38,41 @@ func ShouldShowInitDialog() (bool, error) {
 		return false, fmt.Errorf("failed to check init flag file: %w", err)
 	}
 
+	// Check if any variation of crush.md already exists in working directory
+	crushExists, err := crushMdExists(WorkingDirectory())
+	if err != nil {
+		return false, fmt.Errorf("failed to check for crush.md files: %w", err)
+	}
+	if crushExists {
+		// Crush.md already exists, don't show the dialog
+		return false, nil
+	}
+
 	// File doesn't exist, show the dialog
 	return true, nil
 }
 
+// crushMdExists checks if any case variation of crush.md exists in the directory
+func crushMdExists(dir string) (bool, error) {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return false, err
+	}
+
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+
+		name := strings.ToLower(entry.Name())
+		if name == "crush.md" {
+			return true, nil
+		}
+	}
+
+	return false, nil
+}
+
 // MarkProjectInitialized marks the current project as initialized
 func MarkProjectInitialized() error {
 	if cfg == nil {
@@ -58,4 +90,3 @@ func MarkProjectInitialized() error {
 
 	return nil
 }
-

internal/db/connect.go 🔗

@@ -9,8 +9,8 @@ import (
 	_ "github.com/ncruces/go-sqlite3/driver"
 	_ "github.com/ncruces/go-sqlite3/embed"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/logging"
 
 	"github.com/pressly/goose/v3"
 )
@@ -23,7 +23,7 @@ func Connect() (*sql.DB, error) {
 	if err := os.MkdirAll(dataDir, 0o700); err != nil {
 		return nil, fmt.Errorf("failed to create data directory: %w", err)
 	}
-	dbPath := filepath.Join(dataDir, "opencode.db")
+	dbPath := filepath.Join(dataDir, "crush.db")
 	// Open the SQLite database
 	db, err := sql.Open("sqlite3", dbPath)
 	if err != nil {

internal/diff/diff.go 🔗

@@ -1,22 +1,18 @@
 package diff
 
 import (
-	"bytes"
 	"fmt"
-	"io"
+	"image/color"
 	"regexp"
 	"strconv"
 	"strings"
 
-	"github.com/alecthomas/chroma/v2"
-	"github.com/alecthomas/chroma/v2/formatters"
-	"github.com/alecthomas/chroma/v2/lexers"
-	"github.com/alecthomas/chroma/v2/styles"
 	"github.com/aymanbagabas/go-udiff"
-	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/highlight"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
 	"github.com/sergi/go-diff/diffmatchpatch"
 )
 
@@ -236,10 +232,7 @@ func HighlightIntralineChanges(h *Hunk) {
 
 	for i := 0; i < len(h.Lines); i++ {
 		// Look for removed line followed by added line
-		if i+1 < len(h.Lines) &&
-			h.Lines[i].Kind == LineRemoved &&
-			h.Lines[i+1].Kind == LineAdded {
-
+		if i+1 < len(h.Lines) && h.Lines[i].Kind == LineRemoved && h.Lines[i+1].Kind == LineAdded {
 			oldLine := h.Lines[i]
 			newLine := h.Lines[i+1]
 
@@ -321,241 +314,26 @@ func pairLines(lines []DiffLine) []linePair {
 // -------------------------------------------------------------------------
 // Syntax Highlighting
 // -------------------------------------------------------------------------
-
-// SyntaxHighlight applies syntax highlighting to text based on file extension
-func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
-	t := theme.CurrentTheme()
-
-	// Determine the language lexer to use
-	l := lexers.Match(fileName)
-	if l == nil {
-		l = lexers.Analyse(source)
-	}
-	if l == nil {
-		l = lexers.Fallback
-	}
-	l = chroma.Coalesce(l)
-
-	// Get the formatter
-	f := formatters.Get(formatter)
-	if f == nil {
-		f = formatters.Fallback
-	}
-
-	// Dynamic theme based on current theme values
-	syntaxThemeXml := fmt.Sprintf(`
-	<style name="opencode-theme">
-	<!-- Base colors -->
-	<entry type="Background" style="bg:%s"/>
-	<entry type="Text" style="%s"/>
-	<entry type="Other" style="%s"/>
-	<entry type="Error" style="%s"/>
-	<!-- Keywords -->
-	<entry type="Keyword" style="%s"/>
-	<entry type="KeywordConstant" style="%s"/>
-	<entry type="KeywordDeclaration" style="%s"/>
-	<entry type="KeywordNamespace" style="%s"/>
-	<entry type="KeywordPseudo" style="%s"/>
-	<entry type="KeywordReserved" style="%s"/>
-	<entry type="KeywordType" style="%s"/>
-	<!-- Names -->
-	<entry type="Name" style="%s"/>
-	<entry type="NameAttribute" style="%s"/>
-	<entry type="NameBuiltin" style="%s"/>
-	<entry type="NameBuiltinPseudo" style="%s"/>
-	<entry type="NameClass" style="%s"/>
-	<entry type="NameConstant" style="%s"/>
-	<entry type="NameDecorator" style="%s"/>
-	<entry type="NameEntity" style="%s"/>
-	<entry type="NameException" style="%s"/>
-	<entry type="NameFunction" style="%s"/>
-	<entry type="NameLabel" style="%s"/>
-	<entry type="NameNamespace" style="%s"/>
-	<entry type="NameOther" style="%s"/>
-	<entry type="NameTag" style="%s"/>
-	<entry type="NameVariable" style="%s"/>
-	<entry type="NameVariableClass" style="%s"/>
-	<entry type="NameVariableGlobal" style="%s"/>
-	<entry type="NameVariableInstance" style="%s"/>
-	<!-- Literals -->
-	<entry type="Literal" style="%s"/>
-	<entry type="LiteralDate" style="%s"/>
-	<entry type="LiteralString" style="%s"/>
-	<entry type="LiteralStringBacktick" style="%s"/>
-	<entry type="LiteralStringChar" style="%s"/>
-	<entry type="LiteralStringDoc" style="%s"/>
-	<entry type="LiteralStringDouble" style="%s"/>
-	<entry type="LiteralStringEscape" style="%s"/>
-	<entry type="LiteralStringHeredoc" style="%s"/>
-	<entry type="LiteralStringInterpol" style="%s"/>
-	<entry type="LiteralStringOther" style="%s"/>
-	<entry type="LiteralStringRegex" style="%s"/>
-	<entry type="LiteralStringSingle" style="%s"/>
-	<entry type="LiteralStringSymbol" style="%s"/>
-	<!-- Numbers -->
-	<entry type="LiteralNumber" style="%s"/>
-	<entry type="LiteralNumberBin" style="%s"/>
-	<entry type="LiteralNumberFloat" style="%s"/>
-	<entry type="LiteralNumberHex" style="%s"/>
-	<entry type="LiteralNumberInteger" style="%s"/>
-	<entry type="LiteralNumberIntegerLong" style="%s"/>
-	<entry type="LiteralNumberOct" style="%s"/>
-	<!-- Operators -->
-	<entry type="Operator" style="%s"/>
-	<entry type="OperatorWord" style="%s"/>
-	<entry type="Punctuation" style="%s"/>
-	<!-- Comments -->
-	<entry type="Comment" style="%s"/>
-	<entry type="CommentHashbang" style="%s"/>
-	<entry type="CommentMultiline" style="%s"/>
-	<entry type="CommentSingle" style="%s"/>
-	<entry type="CommentSpecial" style="%s"/>
-	<entry type="CommentPreproc" style="%s"/>
-	<!-- Generic styles -->
-	<entry type="Generic" style="%s"/>
-	<entry type="GenericDeleted" style="%s"/>
-	<entry type="GenericEmph" style="italic %s"/>
-	<entry type="GenericError" style="%s"/>
-	<entry type="GenericHeading" style="bold %s"/>
-	<entry type="GenericInserted" style="%s"/>
-	<entry type="GenericOutput" style="%s"/>
-	<entry type="GenericPrompt" style="%s"/>
-	<entry type="GenericStrong" style="bold %s"/>
-	<entry type="GenericSubheading" style="bold %s"/>
-	<entry type="GenericTraceback" style="%s"/>
-	<entry type="GenericUnderline" style="underline"/>
-	<entry type="TextWhitespace" style="%s"/>
-</style>
-`,
-		getColor(t.Background()), // Background
-		getColor(t.Text()),       // Text
-		getColor(t.Text()),       // Other
-		getColor(t.Error()),      // Error
-
-		getColor(t.SyntaxKeyword()), // Keyword
-		getColor(t.SyntaxKeyword()), // KeywordConstant
-		getColor(t.SyntaxKeyword()), // KeywordDeclaration
-		getColor(t.SyntaxKeyword()), // KeywordNamespace
-		getColor(t.SyntaxKeyword()), // KeywordPseudo
-		getColor(t.SyntaxKeyword()), // KeywordReserved
-		getColor(t.SyntaxType()),    // KeywordType
-
-		getColor(t.Text()),           // Name
-		getColor(t.SyntaxVariable()), // NameAttribute
-		getColor(t.SyntaxType()),     // NameBuiltin
-		getColor(t.SyntaxVariable()), // NameBuiltinPseudo
-		getColor(t.SyntaxType()),     // NameClass
-		getColor(t.SyntaxVariable()), // NameConstant
-		getColor(t.SyntaxFunction()), // NameDecorator
-		getColor(t.SyntaxVariable()), // NameEntity
-		getColor(t.SyntaxType()),     // NameException
-		getColor(t.SyntaxFunction()), // NameFunction
-		getColor(t.Text()),           // NameLabel
-		getColor(t.SyntaxType()),     // NameNamespace
-		getColor(t.SyntaxVariable()), // NameOther
-		getColor(t.SyntaxKeyword()),  // NameTag
-		getColor(t.SyntaxVariable()), // NameVariable
-		getColor(t.SyntaxVariable()), // NameVariableClass
-		getColor(t.SyntaxVariable()), // NameVariableGlobal
-		getColor(t.SyntaxVariable()), // NameVariableInstance
-
-		getColor(t.SyntaxString()), // Literal
-		getColor(t.SyntaxString()), // LiteralDate
-		getColor(t.SyntaxString()), // LiteralString
-		getColor(t.SyntaxString()), // LiteralStringBacktick
-		getColor(t.SyntaxString()), // LiteralStringChar
-		getColor(t.SyntaxString()), // LiteralStringDoc
-		getColor(t.SyntaxString()), // LiteralStringDouble
-		getColor(t.SyntaxString()), // LiteralStringEscape
-		getColor(t.SyntaxString()), // LiteralStringHeredoc
-		getColor(t.SyntaxString()), // LiteralStringInterpol
-		getColor(t.SyntaxString()), // LiteralStringOther
-		getColor(t.SyntaxString()), // LiteralStringRegex
-		getColor(t.SyntaxString()), // LiteralStringSingle
-		getColor(t.SyntaxString()), // LiteralStringSymbol
-
-		getColor(t.SyntaxNumber()), // LiteralNumber
-		getColor(t.SyntaxNumber()), // LiteralNumberBin
-		getColor(t.SyntaxNumber()), // LiteralNumberFloat
-		getColor(t.SyntaxNumber()), // LiteralNumberHex
-		getColor(t.SyntaxNumber()), // LiteralNumberInteger
-		getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
-		getColor(t.SyntaxNumber()), // LiteralNumberOct
-
-		getColor(t.SyntaxOperator()),    // Operator
-		getColor(t.SyntaxKeyword()),     // OperatorWord
-		getColor(t.SyntaxPunctuation()), // Punctuation
-
-		getColor(t.SyntaxComment()), // Comment
-		getColor(t.SyntaxComment()), // CommentHashbang
-		getColor(t.SyntaxComment()), // CommentMultiline
-		getColor(t.SyntaxComment()), // CommentSingle
-		getColor(t.SyntaxComment()), // CommentSpecial
-		getColor(t.SyntaxKeyword()), // CommentPreproc
-
-		getColor(t.Text()),      // Generic
-		getColor(t.Error()),     // GenericDeleted
-		getColor(t.Text()),      // GenericEmph
-		getColor(t.Error()),     // GenericError
-		getColor(t.Text()),      // GenericHeading
-		getColor(t.Success()),   // GenericInserted
-		getColor(t.TextMuted()), // GenericOutput
-		getColor(t.Text()),      // GenericPrompt
-		getColor(t.Text()),      // GenericStrong
-		getColor(t.Text()),      // GenericSubheading
-		getColor(t.Error()),     // GenericTraceback
-		getColor(t.Text()),      // TextWhitespace
-	)
-
-	r := strings.NewReader(syntaxThemeXml)
-	style := chroma.MustNewXMLStyle(r)
-
-	// Modify the style to use the provided background
-	s, err := style.Builder().Transform(
-		func(t chroma.StyleEntry) chroma.StyleEntry {
-			r, g, b, _ := bg.RGBA()
-			t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
-			return t
-		},
-	).Build()
-	if err != nil {
-		s = styles.Fallback
-	}
-
-	// Tokenize and format
-	it, err := l.Tokenise(nil, source)
-	if err != nil {
-		return err
-	}
-
-	return f.Format(w, s, it)
-}
-
-// getColor returns the appropriate hex color string based on terminal background
-func getColor(adaptiveColor lipgloss.AdaptiveColor) string {
-	if lipgloss.HasDarkBackground() {
-		return adaptiveColor.Dark
-	}
-	return adaptiveColor.Light
+func getColor(c color.Color) string {
+	rgba := color.RGBAModel.Convert(c).(color.RGBA)
+	return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
 }
 
 // highlightLine applies syntax highlighting to a single line
-func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
-	var buf bytes.Buffer
-	err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
+func highlightLine(fileName string, line string, bg color.Color) string {
+	highlighted, err := highlight.SyntaxHighlight(line, fileName, bg)
 	if err != nil {
 		return line
 	}
-	return buf.String()
+	return highlighted
 }
 
 // createStyles generates the lipgloss styles needed for rendering diffs
-func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
-	removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
-	addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
-	contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
-	lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
-
+func createStyles(t *styles.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
+	removedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.RemovedBg)
+	addedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.AddedBg)
+	contextLineStyle = lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
+	lineNumberStyle = lipgloss.NewStyle().Foreground(t.S().Diff.LineNumber)
 	return
 }
 
@@ -563,20 +341,8 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
 // Rendering Functions
 // -------------------------------------------------------------------------
 
-func lipglossToHex(color lipgloss.Color) string {
-	r, g, b, a := color.RGBA()
-
-	// Scale uint32 values (0-65535) to uint8 (0-255).
-	r8 := uint8(r >> 8)
-	g8 := uint8(g >> 8)
-	b8 := uint8(b >> 8)
-	a8 := uint8(a >> 8)
-
-	return fmt.Sprintf("#%02x%02x%02x%02x", r8, g8, b8, a8)
-}
-
 // applyHighlighting applies intra-line highlighting to a piece of text
-func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string {
+func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string {
 	// Find all ANSI sequences in the content
 	ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
 	ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
@@ -616,7 +382,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
 
 	// Get the appropriate color based on terminal background
 	bgColor := lipgloss.Color(getColor(highlightBg))
-	fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
+	// fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
 
 	for i := 0; i < len(content); {
 		// Check if we're at an ANSI sequence
@@ -653,15 +419,15 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
 			currentStyle := ansiSequences[currentPos]
 
 			// Apply foreground and background highlight
-			sb.WriteString("\x1b[38;2;")
-			r, g, b, _ := fgColor.RGBA()
-			sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
+			// sb.WriteString("\x1b[38;2;")
+			// r, g, b, _ := fgColor.RGBA()
+			// sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
 			sb.WriteString("\x1b[48;2;")
-			r, g, b, _ = bgColor.RGBA()
+			r, g, b, _ := bgColor.RGBA()
 			sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
 			sb.WriteString(char)
 			// Reset foreground and background
-			sb.WriteString("\x1b[39m")
+			// sb.WriteString("\x1b[39m")
 
 			// Reapply the original ANSI sequence
 			sb.WriteString(currentStyle)
@@ -679,10 +445,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
 
 // renderLeftColumn formats the left side of a side-by-side diff
 func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
-	t := theme.CurrentTheme()
+	t := styles.CurrentTheme()
 
 	if dl == nil {
-		contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
+		contextLineStyle := t.S().Base.Background(t.S().Diff.ContextBg)
 		return contextLineStyle.Width(colWidth).Render("")
 	}
 
@@ -693,9 +459,9 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
 	var bgStyle lipgloss.Style
 	switch dl.Kind {
 	case LineRemoved:
-		marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
+		marker = removedLineStyle.Foreground(t.S().Diff.Removed).Render("-")
 		bgStyle = removedLineStyle
-		lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
+		lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Removed).Background(t.S().Diff.RemovedLineNumberBg)
 	case LineAdded:
 		marker = "?"
 		bgStyle = contextLineStyle
@@ -718,7 +484,7 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
 
 	// Apply intra-line highlighting for removed lines
 	if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
-		content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
+		content = applyHighlighting(content, dl.Segments, LineRemoved, t.S().Diff.HighlightRemoved)
 	}
 
 	// Add a padding space for removed lines
@@ -732,17 +498,17 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
 		ansi.Truncate(
 			lineText,
 			colWidth,
-			lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
+			lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
 		),
 	)
 }
 
 // renderRightColumn formats the right side of a side-by-side diff
 func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
-	t := theme.CurrentTheme()
+	t := styles.CurrentTheme()
 
 	if dl == nil {
-		contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
+		contextLineStyle := lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
 		return contextLineStyle.Width(colWidth).Render("")
 	}
 
@@ -753,9 +519,9 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
 	var bgStyle lipgloss.Style
 	switch dl.Kind {
 	case LineAdded:
-		marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
+		marker = addedLineStyle.Foreground(t.S().Diff.Added).Render("+")
 		bgStyle = addedLineStyle
-		lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
+		lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Added).Background(t.S().Diff.AddedLineNumberBg)
 	case LineRemoved:
 		marker = "?"
 		bgStyle = contextLineStyle
@@ -778,7 +544,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
 
 	// Apply intra-line highlighting for added lines
 	if dl.Kind == LineAdded && len(dl.Segments) > 0 {
-		content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
+		content = applyHighlighting(content, dl.Segments, LineAdded, t.S().Diff.HighlightAdded)
 	}
 
 	// Add a padding space for added lines
@@ -792,7 +558,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
 		ansi.Truncate(
 			lineText,
 			colWidth,
-			lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
+			lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
 		),
 	)
 }

internal/exp/diffview/diffview_test.go 🔗

@@ -7,9 +7,9 @@ import (
 	"testing"
 
 	"github.com/alecthomas/chroma/v2/styles"
+	"github.com/charmbracelet/crush/internal/exp/diffview"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/golden"
-	"github.com/opencode-ai/opencode/internal/exp/diffview"
 )
 
 //go:embed testdata/TestDefault.before

internal/fileutil/fileutil.go 🔗

@@ -2,7 +2,6 @@ package fileutil
 
 import (
 	"fmt"
-	"io/fs"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -11,7 +10,9 @@ import (
 	"time"
 
 	"github.com/bmatcuk/doublestar/v4"
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/charlievieth/fastwalk"
+	"github.com/charmbracelet/crush/internal/logging"
+	ignore "github.com/sabhiram/go-gitignore"
 )
 
 var (
@@ -53,21 +54,6 @@ func GetRgCmd(globPattern string) *exec.Cmd {
 	return cmd
 }
 
-func GetFzfCmd(query string) *exec.Cmd {
-	if fzfPath == "" {
-		return nil
-	}
-	fzfArgs := []string{
-		"--filter",
-		query,
-		"--read0",
-		"--print0",
-	}
-	cmd := exec.Command(fzfPath, fzfArgs...)
-	cmd.Dir = "."
-	return cmd
-}
-
 type FileInfo struct {
 	Path    string
 	ModTime time.Time
@@ -81,7 +67,7 @@ func SkipHidden(path string) bool {
 	}
 
 	commonIgnoredDirs := map[string]bool{
-		".opencode":        true,
+		".crush":           true,
 		"node_modules":     true,
 		"vendor":           true,
 		"dist":             true,
@@ -112,37 +98,92 @@ func SkipHidden(path string) bool {
 	return false
 }
 
-func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
-	fsys := os.DirFS(searchPath)
-	relPattern := strings.TrimPrefix(pattern, "/")
+// FastGlobWalker provides gitignore-aware file walking with fastwalk
+type FastGlobWalker struct {
+	gitignore *ignore.GitIgnore
+	rootPath  string
+}
+
+func NewFastGlobWalker(searchPath string) *FastGlobWalker {
+	walker := &FastGlobWalker{
+		rootPath: searchPath,
+	}
+
+	// Load gitignore if it exists
+	gitignorePath := filepath.Join(searchPath, ".gitignore")
+	if _, err := os.Stat(gitignorePath); err == nil {
+		if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
+			walker.gitignore = gi
+		}
+	}
+
+	return walker
+}
+
+func (w *FastGlobWalker) shouldSkip(path string) bool {
+	if SkipHidden(path) {
+		return true
+	}
+
+	if w.gitignore != nil {
+		relPath, err := filepath.Rel(w.rootPath, path)
+		if err == nil && w.gitignore.MatchesPath(relPath) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
+	walker := NewFastGlobWalker(searchPath)
 	var matches []FileInfo
+	conf := fastwalk.Config{
+		Follow: true,
+		// Use forward slashes when running a Windows binary under WSL or MSYS
+		ToSlash: fastwalk.DefaultToSlash(),
+		Sort:    fastwalk.SortFilesFirst,
+	}
+	err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
+		if err != nil {
+			return nil // Skip files we can't access
+		}
 
-	err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
 		if d.IsDir() {
+			if walker.shouldSkip(path) {
+				return filepath.SkipDir
+			}
 			return nil
 		}
-		if SkipHidden(path) {
+
+		if walker.shouldSkip(path) {
 			return nil
 		}
-		info, err := d.Info()
+
+		// Check if path matches the pattern
+		relPath, err := filepath.Rel(searchPath, path)
 		if err != nil {
+			relPath = path
+		}
+
+		matched, err := doublestar.Match(pattern, relPath)
+		if err != nil || !matched {
 			return nil
 		}
-		absPath := path
-		if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
-			absPath = filepath.Join(searchPath, absPath)
-		} else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
-			absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
+
+		info, err := d.Info()
+		if err != nil {
+			return nil
 		}
 
-		matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
+		matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
 		if limit > 0 && len(matches) >= limit*2 {
-			return fs.SkipAll
+			return filepath.SkipAll
 		}
 		return nil
 	})
 	if err != nil {
-		return nil, false, fmt.Errorf("glob walk error: %w", err)
+		return nil, false, fmt.Errorf("fastwalk error: %w", err)
 	}
 
 	sort.Slice(matches, func(i, j int) bool {
@@ -161,3 +202,12 @@ func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool,
 	}
 	return results, truncated, nil
 }
+
+func PrettyPath(path string) string {
+	// replace home directory with ~
+	homeDir, err := os.UserHomeDir()
+	if err == nil {
+		path = strings.ReplaceAll(path, homeDir, "~")
+	}
+	return path
+}

internal/fileutil/ls.go 🔗

@@ -0,0 +1,169 @@
+package fileutil
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/charlievieth/fastwalk"
+	ignore "github.com/sabhiram/go-gitignore"
+)
+
+// CommonIgnorePatterns contains commonly ignored files and directories
+var CommonIgnorePatterns = []string{
+	// Version control
+	".git",
+	".svn",
+	".hg",
+	".bzr",
+
+	// IDE and editor files
+	".vscode",
+	".idea",
+	"*.swp",
+	"*.swo",
+	"*~",
+	".DS_Store",
+	"Thumbs.db",
+
+	// Build artifacts and dependencies
+	"node_modules",
+	"target",
+	"build",
+	"dist",
+	"out",
+	"bin",
+	"obj",
+	"*.o",
+	"*.so",
+	"*.dylib",
+	"*.dll",
+	"*.exe",
+
+	// Logs and temporary files
+	"*.log",
+	"*.tmp",
+	"*.temp",
+	".cache",
+	".tmp",
+
+	// Language-specific
+	"__pycache__",
+	"*.pyc",
+	"*.pyo",
+	".pytest_cache",
+	"vendor",
+	"Cargo.lock",
+	"package-lock.json",
+	"yarn.lock",
+	"pnpm-lock.yaml",
+
+	// OS generated files
+	".Trash",
+	".Spotlight-V100",
+	".fseventsd",
+
+	// Crush
+	".crush",
+}
+
+type DirectoryLister struct {
+	gitignore    *ignore.GitIgnore
+	commonIgnore *ignore.GitIgnore
+	rootPath     string
+}
+
+func NewDirectoryLister(rootPath string) *DirectoryLister {
+	dl := &DirectoryLister{
+		rootPath: rootPath,
+	}
+
+	// Load gitignore if it exists
+	gitignorePath := filepath.Join(rootPath, ".gitignore")
+	if _, err := os.Stat(gitignorePath); err == nil {
+		if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
+			dl.gitignore = gi
+		}
+	}
+
+	// Create common ignore patterns
+	dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...)
+
+	return dl
+}
+
+func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
+	relPath, err := filepath.Rel(dl.rootPath, path)
+	if err != nil {
+		relPath = path
+	}
+
+	// Check common ignore patterns
+	if dl.commonIgnore.MatchesPath(relPath) {
+		return true
+	}
+
+	// Check gitignore patterns if available
+	if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) {
+		return true
+	}
+
+	base := filepath.Base(path)
+
+	if base != "." && strings.HasPrefix(base, ".") {
+		return true
+	}
+
+	for _, pattern := range ignorePatterns {
+		matched, err := filepath.Match(pattern, base)
+		if err == nil && matched {
+			return true
+		}
+	}
+	return false
+}
+
+// ListDirectory lists files and directories in the specified path,
+func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
+	var results []string
+	truncated := false
+	dl := NewDirectoryLister(initialPath)
+
+	conf := fastwalk.Config{
+		Follow: true,
+		// Use forward slashes when running a Windows binary under WSL or MSYS
+		ToSlash: fastwalk.DefaultToSlash(),
+		Sort:    fastwalk.SortDirsFirst,
+	}
+	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
+		if err != nil {
+			return nil // Skip files we don't have permission to access
+		}
+
+		if dl.shouldIgnore(path, ignorePatterns) {
+			if d.IsDir() {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+
+		if path != initialPath {
+			if d.IsDir() {
+				path = path + string(filepath.Separator)
+			}
+			results = append(results, path)
+		}
+
+		if limit > 0 && len(results) >= limit {
+			truncated = true
+			return filepath.SkipAll
+		}
+
+		return nil
+	})
+	if err != nil {
+		return nil, truncated, err
+	}
+
+	return results, truncated, nil
+}

internal/format/spinner.go 🔗

@@ -5,8 +5,8 @@ import (
 	"fmt"
 	"os"
 
-	"github.com/charmbracelet/bubbles/spinner"
-	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/bubbles/v2/spinner"
+	tea "github.com/charmbracelet/bubbletea/v2"
 )
 
 // Spinner wraps the bubbles spinner for non-interactive mode
@@ -31,7 +31,7 @@ func (m spinnerModel) Init() tea.Cmd {
 
 func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case tea.KeyMsg:
+	case tea.KeyPressMsg:
 		m.quitting = true
 		return m, tea.Quit
 	case spinner.TickMsg:

internal/highlight/highlight.go 🔗

@@ -0,0 +1,60 @@
+package highlight
+
+import (
+	"bytes"
+	"fmt"
+	"image/color"
+
+	"github.com/alecthomas/chroma/v2"
+	"github.com/alecthomas/chroma/v2/formatters"
+	"github.com/alecthomas/chroma/v2/lexers"
+	chromaStyles "github.com/alecthomas/chroma/v2/styles"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+)
+
+func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
+	// Determine the language lexer to use
+	l := lexers.Match(fileName)
+	if l == nil {
+		l = lexers.Analyse(source)
+	}
+	if l == nil {
+		l = lexers.Fallback
+	}
+	l = chroma.Coalesce(l)
+
+	// Get the formatter
+	f := formatters.Get("terminal16m")
+	if f == nil {
+		f = formatters.Fallback
+	}
+
+	style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
+
+	// Modify the style to use the provided background
+	s, err := style.Builder().Transform(
+		func(t chroma.StyleEntry) chroma.StyleEntry {
+			r, g, b, _ := bg.RGBA()
+			t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
+			return t
+		},
+	).Build()
+	if err != nil {
+		s = chromaStyles.Fallback
+	}
+
+	// Tokenize and format
+	it, err := l.Tokenise(nil, source)
+	if err != nil {
+		return "", err
+	}
+
+	var buf bytes.Buffer
+	err = f.Format(&buf, s, it)
+	return buf.String(), err
+}
+
+func getColor(c color.Color) string {
+	rgba := color.RGBAModel.Convert(c).(color.RGBA)
+	return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
+}

internal/history/file.go 🔗

@@ -8,9 +8,9 @@ import (
 	"strings"
 	"time"
 
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/google/uuid"
-	"github.com/opencode-ai/opencode/internal/db"
-	"github.com/opencode-ai/opencode/internal/pubsub"
 )
 
 const (

internal/llm/agent/agent-tool.go 🔗

@@ -5,11 +5,11 @@ import (
 	"encoding/json"
 	"fmt"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/session"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
 )
 
 type agentTool struct {

internal/llm/agent/agent.go 🔗

@@ -8,16 +8,16 @@ import (
 	"sync"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/llm/prompt"
-	"github.com/opencode-ai/opencode/internal/llm/provider"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/permission"
-	"github.com/opencode-ai/opencode/internal/pubsub"
-	"github.com/opencode-ai/opencode/internal/session"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/llm/prompt"
+	"github.com/charmbracelet/crush/internal/llm/provider"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/session"
 )
 
 // Common errors
@@ -443,18 +443,14 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
 		assistantMsg.AppendContent(event.Content)
 		return a.messages.Update(ctx, *assistantMsg)
 	case provider.EventToolUseStart:
+		logging.Info("Tool call started", "toolCall", event.ToolCall)
 		assistantMsg.AddToolCall(*event.ToolCall)
 		return a.messages.Update(ctx, *assistantMsg)
-	// TODO: see how to handle this
-	// case provider.EventToolUseDelta:
-	// 	tm := time.Unix(assistantMsg.UpdatedAt, 0)
-	// 	assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
-	// 	if time.Since(tm) > 1000*time.Millisecond {
-	// 		err := a.messages.Update(ctx, *assistantMsg)
-	// 		assistantMsg.UpdatedAt = time.Now().Unix()
-	// 		return err
-	// 	}
+	case provider.EventToolUseDelta:
+		assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
+		return a.messages.Update(ctx, *assistantMsg)
 	case provider.EventToolUseStop:
+		logging.Info("Finished tool call", "toolCall", event.ToolCall)
 		assistantMsg.FinishToolCall(event.ToolCall.ID)
 		return a.messages.Update(ctx, *assistantMsg)
 	case provider.EventError:
@@ -590,22 +586,26 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
 		a.Publish(pubsub.CreatedEvent, event)
 
 		// Send the messages to the summarize provider
-		response, err := a.summarizeProvider.SendMessages(
+		response := a.summarizeProvider.StreamResponse(
 			summarizeCtx,
 			msgsWithPrompt,
 			make([]tools.BaseTool, 0),
 		)
-		if err != nil {
-			event = AgentEvent{
-				Type:  AgentEventTypeError,
-				Error: fmt.Errorf("failed to summarize: %w", err),
-				Done:  true,
+		var finalResponse *provider.ProviderResponse
+		for r := range response {
+			if r.Error != nil {
+				event = AgentEvent{
+					Type:  AgentEventTypeError,
+					Error: fmt.Errorf("failed to summarize: %w", err),
+					Done:  true,
+				}
+				a.Publish(pubsub.CreatedEvent, event)
+				return
 			}
-			a.Publish(pubsub.CreatedEvent, event)
-			return
+			finalResponse = r.Response
 		}
 
-		summary := strings.TrimSpace(response.Content)
+		summary := strings.TrimSpace(finalResponse.Content)
 		if summary == "" {
 			event = AgentEvent{
 				Type:  AgentEventTypeError,
@@ -655,10 +655,10 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
 			return
 		}
 		oldSession.SummaryMessageID = msg.ID
-		oldSession.CompletionTokens = response.Usage.OutputTokens
+		oldSession.CompletionTokens = finalResponse.Usage.OutputTokens
 		oldSession.PromptTokens = 0
 		model := a.summarizeProvider.Model()
-		usage := response.Usage
+		usage := finalResponse.Usage
 		cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
 			model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
 			model.CostPer1MIn/1e6*float64(usage.InputTokens) +

internal/llm/agent/mcp-tools.go 🔗

@@ -5,11 +5,11 @@ import (
 	"encoding/json"
 	"fmt"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/permission"
-	"github.com/opencode-ai/opencode/internal/version"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/version"
 
 	"github.com/mark3labs/mcp-go/client"
 	"github.com/mark3labs/mcp-go/mcp"
@@ -46,7 +46,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
 	initRequest := mcp.InitializeRequest{}
 	initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
 	initRequest.Params.ClientInfo = mcp.Implementation{
-		Name:    "OpenCode",
+		Name:    "Crush",
 		Version: version.Version,
 	}
 
@@ -140,7 +140,7 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
 	initRequest := mcp.InitializeRequest{}
 	initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
 	initRequest.Params.ClientInfo = mcp.Implementation{
-		Name:    "OpenCode",
+		Name:    "Crush",
 		Version: version.Version,
 	}
 

internal/llm/agent/tools.go 🔗

@@ -3,12 +3,12 @@ package agent
 import (
 	"context"
 
-	"github.com/opencode-ai/opencode/internal/history"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/permission"
-	"github.com/opencode-ai/opencode/internal/session"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
 )
 
 func CoderAgentTools(

internal/llm/models/local.go 🔗

@@ -2,6 +2,7 @@ package models
 
 import (
 	"cmp"
+	"context"
 	"encoding/json"
 	"net/http"
 	"net/url"
@@ -10,7 +11,7 @@ import (
 	"strings"
 	"unicode"
 
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/charmbracelet/crush/internal/logging"
 	"github.com/spf13/viper"
 )
 
@@ -53,7 +54,6 @@ func init() {
 		loadLocalModels(models)
 
 		viper.SetDefault("providers.local.apiKey", "dummy")
-		ProviderPopularity[ProviderLocal] = 0
 	}
 }
 
@@ -75,7 +75,7 @@ type localModel struct {
 }
 
 func listLocalModels(modelsEndpoint string) []localModel {
-	res, err := http.Get(modelsEndpoint)
+	res, err := http.NewRequestWithContext(context.Background(), http.MethodGet, modelsEndpoint, nil)
 	if err != nil {
 		logging.Debug("Failed to list local models",
 			"error", err,
@@ -84,9 +84,9 @@ func listLocalModels(modelsEndpoint string) []localModel {
 	}
 	defer res.Body.Close()
 
-	if res.StatusCode != http.StatusOK {
+	if res.Response.StatusCode != http.StatusOK {
 		logging.Debug("Failed to list local models",
-			"status", res.StatusCode,
+			"status", res.Response.Status,
 			"endpoint", modelsEndpoint,
 		)
 	}

internal/llm/models/models.go 🔗

@@ -34,44 +34,8 @@ const (
 	ProviderMock ModelProvider = "__mock"
 )
 
-// Providers in order of popularity
-var ProviderPopularity = map[ModelProvider]int{
-	ProviderAnthropic:  1,
-	ProviderOpenAI:     2,
-	ProviderGemini:     3,
-	ProviderGROQ:       4,
-	ProviderOpenRouter: 5,
-	ProviderBedrock:    6,
-	ProviderAzure:      7,
-	ProviderVertexAI:   8,
-}
-
 var SupportedModels = map[ModelID]Model{
-	//
-	// // GEMINI
-	// GEMINI25: {
-	// 	ID:                 GEMINI25,
-	// 	Name:               "Gemini 2.5 Pro",
-	// 	Provider:           ProviderGemini,
-	// 	APIModel:           "gemini-2.5-pro-exp-03-25",
-	// 	CostPer1MIn:        0,
-	// 	CostPer1MInCached:  0,
-	// 	CostPer1MOutCached: 0,
-	// 	CostPer1MOut:       0,
-	// },
-	//
-	// GRMINI20Flash: {
-	// 	ID:                 GRMINI20Flash,
-	// 	Name:               "Gemini 2.0 Flash",
-	// 	Provider:           ProviderGemini,
-	// 	APIModel:           "gemini-2.0-flash",
-	// 	CostPer1MIn:        0.1,
-	// 	CostPer1MInCached:  0,
-	// 	CostPer1MOutCached: 0.025,
-	// 	CostPer1MOut:       0.4,
-	// },
-	//
-	// // Bedrock
+	// Bedrock
 	BedrockClaude37Sonnet: {
 		ID:                 BedrockClaude37Sonnet,
 		Name:               "Bedrock: Claude 3.7 Sonnet",

internal/llm/prompt/coder.go 🔗

@@ -8,9 +8,9 @@ import (
 	"runtime"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/llm/tools"
 )
 
 func CoderPrompt(provider models.ModelProvider) string {
@@ -25,7 +25,7 @@ func CoderPrompt(provider models.ModelProvider) string {
 }
 
 const baseOpenAICoderPrompt = `
-You are operating as and within the OpenCode CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
+You are operating as and within the Crush CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
 
 You can:
 - Receive user prompts, project context, and files.
@@ -33,7 +33,7 @@ You can:
 - Apply patches, run commands, and manage user approvals based on policy.
 - Work inside a sandboxed, git-backed workspace with rollback support.
 - Log telemetry so sessions can be replayed or inspected later.
-- More details on your functionality are available at "opencode --help"
+- More details on your functionality are available at "crush --help"
 
 
 You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
@@ -71,17 +71,17 @@ You MUST adhere to the following criteria when executing the task:
 - Remember the user does not see the full output of tools
 `
 
-const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
+const baseAnthropicCoderPrompt = `You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
 
 IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
 
 # Memory
-If the current working directory contains a file called OpenCode.md, it will be automatically added to your context. This file serves multiple purposes:
+If the current working directory contains a file called Crush.md, it will be automatically added to your context. This file serves multiple purposes:
 1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
 2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
 3. Maintaining useful information about the codebase structure and organization
 
-When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to OpenCode.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to OpenCode.md so you can remember it for next time.
+When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to Crush.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to Crush.md so you can remember it for next time.
 
 # Tone and style
 You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
@@ -153,16 +153,16 @@ When making changes to files, first understand the file's code conventions. Mimi
 
 # Doing tasks
 The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
-1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
+1. Use the available search tools to understand the codebase and the user's query.
 2. Implement the solution using all tools available to you
 3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
-4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to opencode.md so that you will know to run it next time.
+4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to crush.md so that you will know to run it next time.
 
 NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
 
 # Tool usage policy
 - When doing file search, prefer to use the Agent tool in order to reduce context usage.
-- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
+- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
 - IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
 
 You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`

internal/llm/prompt/prompt.go 🔗

@@ -7,9 +7,9 @@ import (
 	"strings"
 	"sync"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/logging"
 )
 
 func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {

internal/llm/prompt/prompt_test.go 🔗

@@ -6,7 +6,7 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -44,13 +44,13 @@ func createTestFiles(t *testing.T, tmpDir string, testFiles []string) {
 	for _, path := range testFiles {
 		fullPath := filepath.Join(tmpDir, path)
 		if path[len(path)-1] == '/' {
-			err := os.MkdirAll(fullPath, 0755)
+			err := os.MkdirAll(fullPath, 0o755)
 			require.NoError(t, err)
 		} else {
 			dir := filepath.Dir(fullPath)
-			err := os.MkdirAll(dir, 0755)
+			err := os.MkdirAll(dir, 0o755)
 			require.NoError(t, err)
-			err = os.WriteFile(fullPath, []byte(path+": test content"), 0644)
+			err = os.WriteFile(fullPath, []byte(path+": test content"), 0o644)
 			require.NoError(t, err)
 		}
 	}

internal/llm/prompt/summarizer.go 🔗

@@ -1,6 +1,6 @@
 package prompt
 
-import "github.com/opencode-ai/opencode/internal/llm/models"
+import "github.com/charmbracelet/crush/internal/llm/models"
 
 func SummarizerPrompt(_ models.ModelProvider) string {
 	return `You are a helpful AI assistant tasked with summarizing conversations.

internal/llm/prompt/task.go 🔗

@@ -3,11 +3,11 @@ package prompt
 import (
 	"fmt"
 
-	"github.com/opencode-ai/opencode/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/llm/models"
 )
 
 func TaskPrompt(_ models.ModelProvider) string {
-	agentPrompt := `You are an agent for OpenCode. Given the user's prompt, you should use the tools available to you to answer the user's question.
+	agentPrompt := `You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
 Notes:
 1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
 2. When relevant, share file names and code snippets relevant to the query

internal/llm/prompt/title.go 🔗

@@ -1,6 +1,6 @@
 package prompt
 
-import "github.com/opencode-ai/opencode/internal/llm/models"
+import "github.com/charmbracelet/crush/internal/llm/models"
 
 func TitlePrompt(_ models.ModelProvider) string {
 	return `you will generate a short title based on the first message a user begins a conversation with

internal/llm/provider/anthropic.go 🔗

@@ -12,11 +12,11 @@ import (
 	"github.com/anthropics/anthropic-sdk-go"
 	"github.com/anthropics/anthropic-sdk-go/bedrock"
 	"github.com/anthropics/anthropic-sdk-go/option"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/message"
 )
 
 type anthropicOptions struct {
@@ -195,7 +195,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
 	}
 }
 
-func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) {
+func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
 	preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
 	cfg := config.Get()
 	if cfg.Debug {
@@ -305,7 +305,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
 								ToolCall: &message.ToolCall{
 									ID:       currentToolCallID,
 									Finished: false,
-									Input:    event.Delta.JSON.PartialJSON.Raw(),
+									Input:    event.Delta.PartialJSON,
 								},
 							}
 						}
@@ -339,6 +339,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
 							Usage:        a.usage(accumulatedMessage),
 							FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
 						},
+						Content: content,
 					}
 				}
 			}

internal/llm/provider/azure.go 🔗

@@ -16,7 +16,6 @@ type azureClient struct {
 type AzureClient ProviderClient
 
 func newAzureClient(opts providerClientOptions) AzureClient {
-
 	endpoint := os.Getenv("AZURE_OPENAI_ENDPOINT")      // ex: https://foo.openai.azure.com
 	apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") // ex: 2025-04-01-preview
 

internal/llm/provider/bedrock.go 🔗

@@ -7,8 +7,8 @@ import (
 	"os"
 	"strings"
 
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/message"
 )
 
 type bedrockOptions struct {
@@ -98,4 +98,3 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
 
 	return b.childProvider.stream(ctx, messages, tools)
 }
-

internal/llm/provider/gemini.go 🔗

@@ -9,11 +9,11 @@ import (
 	"strings"
 	"time"
 
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/message"
 	"github.com/google/uuid"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/message"
 	"google.golang.org/genai"
 )
 
@@ -365,7 +365,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
 			eventChan <- ProviderEvent{Type: EventContentStop}
 
 			if finalResp != nil {
-
 				finishReason := message.FinishReasonEndTurn
 				if len(finalResp.Candidates) > 0 {
 					finishReason = g.finishReason(finalResp.Candidates[0].FinishReason)
@@ -384,7 +383,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
 				}
 				return
 			}
-
 		}
 	}()
 

internal/llm/provider/openai.go 🔗

@@ -8,14 +8,14 @@ import (
 	"io"
 	"time"
 
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/message"
 	"github.com/openai/openai-go"
 	"github.com/openai/openai-go/option"
 	"github.com/openai/openai-go/shared"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/message"
 )
 
 type openaiOptions struct {

internal/llm/provider/provider.go 🔗

@@ -5,9 +5,9 @@ import (
 	"fmt"
 	"os"
 
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/message"
 )
 
 type EventType string
@@ -130,8 +130,8 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
 		clientOptions.openaiOptions = append(clientOptions.openaiOptions,
 			WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
 			WithOpenAIExtraHeaders(map[string]string{
-				"HTTP-Referer": "opencode.ai",
-				"X-Title":      "OpenCode",
+				"HTTP-Referer": "crush.ai",
+				"X-Title":      "Crush",
 			}),
 		)
 		return &baseProvider[OpenAIClient]{

internal/llm/provider/vertexai.go 🔗

@@ -4,7 +4,7 @@ import (
 	"context"
 	"os"
 
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/charmbracelet/crush/internal/logging"
 	"google.golang.org/genai"
 )
 

internal/llm/tools/bash.go 🔗

@@ -7,9 +7,9 @@ import (
 	"strings"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/tools/shell"
-	"github.com/opencode-ai/opencode/internal/permission"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/tools/shell"
+	"github.com/charmbracelet/crush/internal/permission"
 )
 
 type BashParams struct {
@@ -122,16 +122,16 @@ When the user asks you to create a new git commit, follow these steps carefully:
 </commit_analysis>
 
 4. Create the commit with a message ending with:
-🤖 Generated with opencode
-Co-Authored-By: opencode <noreply@opencode.ai>
+🤖 Generated with crush
+Co-Authored-By: crush <noreply@crush.ai>
 
 - In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
 <example>
 git commit -m "$(cat <<'EOF'
  Commit message here.
 
- 🤖 Generated with opencode
- Co-Authored-By: opencode <noreply@opencode.ai>
+ 🤖 Generated with crush
+ Co-Authored-By: crush <noreply@crush.ai>
  EOF
  )"
 </example>
@@ -193,7 +193,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
 ## Test plan
 [Checklist of TODOs for testing the pull request...]
 
-🤖 Generated with opencode
+🤖 Generated with crush
 EOF
 )"
 </example>

internal/llm/tools/diagnostics.go 🔗

@@ -9,8 +9,8 @@ import (
 	"strings"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
 )
 
 type DiagnosticsParams struct {

internal/llm/tools/edit.go 🔗

@@ -9,12 +9,12 @@ import (
 	"strings"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/diff"
-	"github.com/opencode-ai/opencode/internal/history"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/permission"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
 )
 
 type EditParams struct {

internal/llm/tools/fetch.go 🔗

@@ -11,8 +11,8 @@ import (
 
 	md "github.com/JohannesKaufmann/html-to-markdown"
 	"github.com/PuerkitoBio/goquery"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/permission"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/permission"
 )
 
 type FetchParams struct {
@@ -152,7 +152,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
 		return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
 	}
 
-	req.Header.Set("User-Agent", "opencode/1.0")
+	req.Header.Set("User-Agent", "crush/1.0")
 
 	resp, err := client.Do(req)
 	if err != nil {

internal/llm/tools/glob.go 🔗

@@ -10,9 +10,9 @@ import (
 	"sort"
 	"strings"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/fileutil"
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/fileutil"
+	"github.com/charmbracelet/crush/internal/logging"
 )
 
 const (
@@ -137,7 +137,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
 		logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
 	}
 
-	return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
+	return fileutil.GlobWithDoubleStar(pattern, searchPath, limit)
 }
 
 func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {

internal/llm/tools/grep.go 🔗

@@ -14,8 +14,8 @@ import (
 	"strings"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/fileutil"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/fileutil"
 )
 
 type GrepParams struct {

internal/llm/tools/ls.go 🔗

@@ -8,7 +8,8 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/fileutil"
 )
 
 type LSParams struct {
@@ -107,7 +108,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 		return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
 	}
 
-	files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
+	files, truncated, err := fileutil.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
 	if err != nil {
 		return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
 	}
@@ -128,101 +129,6 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 	), nil
 }
 
-func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
-	var results []string
-	truncated := false
-
-	err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
-		if err != nil {
-			return nil // Skip files we don't have permission to access
-		}
-
-		if shouldSkip(path, ignorePatterns) {
-			if info.IsDir() {
-				return filepath.SkipDir
-			}
-			return nil
-		}
-
-		if path != initialPath {
-			if info.IsDir() {
-				path = path + string(filepath.Separator)
-			}
-			results = append(results, path)
-		}
-
-		if len(results) >= limit {
-			truncated = true
-			return filepath.SkipAll
-		}
-
-		return nil
-	})
-	if err != nil {
-		return nil, truncated, err
-	}
-
-	return results, truncated, nil
-}
-
-func shouldSkip(path string, ignorePatterns []string) bool {
-	base := filepath.Base(path)
-
-	if base != "." && strings.HasPrefix(base, ".") {
-		return true
-	}
-
-	commonIgnored := []string{
-		"__pycache__",
-		"node_modules",
-		"dist",
-		"build",
-		"target",
-		"vendor",
-		"bin",
-		"obj",
-		".git",
-		".idea",
-		".vscode",
-		".DS_Store",
-		"*.pyc",
-		"*.pyo",
-		"*.pyd",
-		"*.so",
-		"*.dll",
-		"*.exe",
-	}
-
-	if strings.Contains(path, filepath.Join("__pycache__", "")) {
-		return true
-	}
-
-	for _, ignored := range commonIgnored {
-		if strings.HasSuffix(ignored, "/") {
-			if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
-				return true
-			}
-		} else if strings.HasPrefix(ignored, "*.") {
-			if strings.HasSuffix(base, ignored[1:]) {
-				return true
-			}
-		} else {
-			if base == ignored {
-				return true
-			}
-		}
-	}
-
-	for _, pattern := range ignorePatterns {
-		matched, err := filepath.Match(pattern, base)
-		if err == nil && matched {
-			return true
-		}
-	}
-
-	return false
-}
-
 func createFileTree(sortedPaths []string) []*TreeNode {
 	root := []*TreeNode{}
 	pathMap := make(map[string]*TreeNode)

internal/llm/tools/ls_test.go 🔗

@@ -1,457 +0,0 @@
-package tools
-
-import (
-	"context"
-	"encoding/json"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestLsTool_Info(t *testing.T) {
-	tool := NewLsTool()
-	info := tool.Info()
-
-	assert.Equal(t, LSToolName, info.Name)
-	assert.NotEmpty(t, info.Description)
-	assert.Contains(t, info.Parameters, "path")
-	assert.Contains(t, info.Parameters, "ignore")
-	assert.Contains(t, info.Required, "path")
-}
-
-func TestLsTool_Run(t *testing.T) {
-	// Create a temporary directory for testing
-	tempDir, err := os.MkdirTemp("", "ls_tool_test")
-	require.NoError(t, err)
-	defer os.RemoveAll(tempDir)
-
-	// Create a test directory structure
-	testDirs := []string{
-		"dir1",
-		"dir2",
-		"dir2/subdir1",
-		"dir2/subdir2",
-		"dir3",
-		"dir3/.hidden_dir",
-		"__pycache__",
-	}
-
-	testFiles := []string{
-		"file1.txt",
-		"file2.txt",
-		"dir1/file3.txt",
-		"dir2/file4.txt",
-		"dir2/subdir1/file5.txt",
-		"dir2/subdir2/file6.txt",
-		"dir3/file7.txt",
-		"dir3/.hidden_file.txt",
-		"__pycache__/cache.pyc",
-		".hidden_root_file.txt",
-	}
-
-	// Create directories
-	for _, dir := range testDirs {
-		dirPath := filepath.Join(tempDir, dir)
-		err := os.MkdirAll(dirPath, 0755)
-		require.NoError(t, err)
-	}
-
-	// Create files
-	for _, file := range testFiles {
-		filePath := filepath.Join(tempDir, file)
-		err := os.WriteFile(filePath, []byte("test content"), 0644)
-		require.NoError(t, err)
-	}
-
-	t.Run("lists directory successfully", func(t *testing.T) {
-		tool := NewLsTool()
-		params := LSParams{
-			Path: tempDir,
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  LSToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		
-		// Check that visible directories and files are included
-		assert.Contains(t, response.Content, "dir1")
-		assert.Contains(t, response.Content, "dir2")
-		assert.Contains(t, response.Content, "dir3")
-		assert.Contains(t, response.Content, "file1.txt")
-		assert.Contains(t, response.Content, "file2.txt")
-		
-		// Check that hidden files and directories are not included
-		assert.NotContains(t, response.Content, ".hidden_dir")
-		assert.NotContains(t, response.Content, ".hidden_file.txt")
-		assert.NotContains(t, response.Content, ".hidden_root_file.txt")
-		
-		// Check that __pycache__ is not included
-		assert.NotContains(t, response.Content, "__pycache__")
-	})
-
-	t.Run("handles non-existent path", func(t *testing.T) {
-		tool := NewLsTool()
-		params := LSParams{
-			Path: filepath.Join(tempDir, "non_existent_dir"),
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  LSToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "path does not exist")
-	})
-
-	t.Run("handles empty path parameter", func(t *testing.T) {
-		// For this test, we need to mock the config.WorkingDirectory function
-		// Since we can't easily do that, we'll just check that the response doesn't contain an error message
-		
-		tool := NewLsTool()
-		params := LSParams{
-			Path: "",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  LSToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		
-		// The response should either contain a valid directory listing or an error
-		// We'll just check that it's not empty
-		assert.NotEmpty(t, response.Content)
-	})
-
-	t.Run("handles invalid parameters", func(t *testing.T) {
-		tool := NewLsTool()
-		call := ToolCall{
-			Name:  LSToolName,
-			Input: "invalid json",
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "error parsing parameters")
-	})
-
-	t.Run("respects ignore patterns", func(t *testing.T) {
-		tool := NewLsTool()
-		params := LSParams{
-			Path:   tempDir,
-			Ignore: []string{"file1.txt", "dir1"},
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  LSToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		
-		// The output format is a tree, so we need to check for specific patterns
-		// Check that file1.txt is not directly mentioned
-		assert.NotContains(t, response.Content, "- file1.txt")
-		
-		// Check that dir1/ is not directly mentioned
-		assert.NotContains(t, response.Content, "- dir1/")
-	})
-
-	t.Run("handles relative path", func(t *testing.T) {
-		// Save original working directory
-		origWd, err := os.Getwd()
-		require.NoError(t, err)
-		defer func() {
-			os.Chdir(origWd)
-		}()
-		
-		// Change to a directory above the temp directory
-		parentDir := filepath.Dir(tempDir)
-		err = os.Chdir(parentDir)
-		require.NoError(t, err)
-		
-		tool := NewLsTool()
-		params := LSParams{
-			Path: filepath.Base(tempDir),
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  LSToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		
-		// Should list the temp directory contents
-		assert.Contains(t, response.Content, "dir1")
-		assert.Contains(t, response.Content, "file1.txt")
-	})
-}
-
-func TestShouldSkip(t *testing.T) {
-	testCases := []struct {
-		name           string
-		path           string
-		ignorePatterns []string
-		expected       bool
-	}{
-		{
-			name:           "hidden file",
-			path:           "/path/to/.hidden_file",
-			ignorePatterns: []string{},
-			expected:       true,
-		},
-		{
-			name:           "hidden directory",
-			path:           "/path/to/.hidden_dir",
-			ignorePatterns: []string{},
-			expected:       true,
-		},
-		{
-			name:           "pycache directory",
-			path:           "/path/to/__pycache__/file.pyc",
-			ignorePatterns: []string{},
-			expected:       true,
-		},
-		{
-			name:           "node_modules directory",
-			path:           "/path/to/node_modules/package",
-			ignorePatterns: []string{},
-			expected:       false, // The shouldSkip function doesn't directly check for node_modules in the path
-		},
-		{
-			name:           "normal file",
-			path:           "/path/to/normal_file.txt",
-			ignorePatterns: []string{},
-			expected:       false,
-		},
-		{
-			name:           "normal directory",
-			path:           "/path/to/normal_dir",
-			ignorePatterns: []string{},
-			expected:       false,
-		},
-		{
-			name:           "ignored by pattern",
-			path:           "/path/to/ignore_me.txt",
-			ignorePatterns: []string{"ignore_*.txt"},
-			expected:       true,
-		},
-		{
-			name:           "not ignored by pattern",
-			path:           "/path/to/keep_me.txt",
-			ignorePatterns: []string{"ignore_*.txt"},
-			expected:       false,
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			result := shouldSkip(tc.path, tc.ignorePatterns)
-			assert.Equal(t, tc.expected, result)
-		})
-	}
-}
-
-func TestCreateFileTree(t *testing.T) {
-	paths := []string{
-		"/path/to/file1.txt",
-		"/path/to/dir1/file2.txt",
-		"/path/to/dir1/subdir/file3.txt",
-		"/path/to/dir2/file4.txt",
-	}
-
-	tree := createFileTree(paths)
-	
-	// Check the structure of the tree
-	assert.Len(t, tree, 1) // Should have one root node
-	
-	// Check the root node
-	rootNode := tree[0]
-	assert.Equal(t, "path", rootNode.Name)
-	assert.Equal(t, "directory", rootNode.Type)
-	assert.Len(t, rootNode.Children, 1)
-	
-	// Check the "to" node
-	toNode := rootNode.Children[0]
-	assert.Equal(t, "to", toNode.Name)
-	assert.Equal(t, "directory", toNode.Type)
-	assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2
-	
-	// Find the dir1 node
-	var dir1Node *TreeNode
-	for _, child := range toNode.Children {
-		if child.Name == "dir1" {
-			dir1Node = child
-			break
-		}
-	}
-	
-	require.NotNil(t, dir1Node)
-	assert.Equal(t, "directory", dir1Node.Type)
-	assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir
-}
-
-func TestPrintTree(t *testing.T) {
-	// Create a simple tree
-	tree := []*TreeNode{
-		{
-			Name: "dir1",
-			Path: "dir1",
-			Type: "directory",
-			Children: []*TreeNode{
-				{
-					Name: "file1.txt",
-					Path: "dir1/file1.txt",
-					Type: "file",
-				},
-				{
-					Name: "subdir",
-					Path: "dir1/subdir",
-					Type: "directory",
-					Children: []*TreeNode{
-						{
-							Name: "file2.txt",
-							Path: "dir1/subdir/file2.txt",
-							Type: "file",
-						},
-					},
-				},
-			},
-		},
-		{
-			Name: "file3.txt",
-			Path: "file3.txt",
-			Type: "file",
-		},
-	}
-	
-	result := printTree(tree, "/root")
-	
-	// Check the output format
-	assert.Contains(t, result, "- /root/")
-	assert.Contains(t, result, "  - dir1/")
-	assert.Contains(t, result, "    - file1.txt")
-	assert.Contains(t, result, "    - subdir/")
-	assert.Contains(t, result, "      - file2.txt")
-	assert.Contains(t, result, "  - file3.txt")
-}
-
-func TestListDirectory(t *testing.T) {
-	// Create a temporary directory for testing
-	tempDir, err := os.MkdirTemp("", "list_directory_test")
-	require.NoError(t, err)
-	defer os.RemoveAll(tempDir)
-
-	// Create a test directory structure
-	testDirs := []string{
-		"dir1",
-		"dir1/subdir1",
-		".hidden_dir",
-	}
-
-	testFiles := []string{
-		"file1.txt",
-		"file2.txt",
-		"dir1/file3.txt",
-		"dir1/subdir1/file4.txt",
-		".hidden_file.txt",
-	}
-
-	// Create directories
-	for _, dir := range testDirs {
-		dirPath := filepath.Join(tempDir, dir)
-		err := os.MkdirAll(dirPath, 0755)
-		require.NoError(t, err)
-	}
-
-	// Create files
-	for _, file := range testFiles {
-		filePath := filepath.Join(tempDir, file)
-		err := os.WriteFile(filePath, []byte("test content"), 0644)
-		require.NoError(t, err)
-	}
-
-	t.Run("lists files with no limit", func(t *testing.T) {
-		files, truncated, err := listDirectory(tempDir, []string{}, 1000)
-		require.NoError(t, err)
-		assert.False(t, truncated)
-		
-		// Check that visible files and directories are included
-		containsPath := func(paths []string, target string) bool {
-			targetPath := filepath.Join(tempDir, target)
-			for _, path := range paths {
-				if strings.HasPrefix(path, targetPath) {
-					return true
-				}
-			}
-			return false
-		}
-		
-		assert.True(t, containsPath(files, "dir1"))
-		assert.True(t, containsPath(files, "file1.txt"))
-		assert.True(t, containsPath(files, "file2.txt"))
-		assert.True(t, containsPath(files, "dir1/file3.txt"))
-		
-		// Check that hidden files and directories are not included
-		assert.False(t, containsPath(files, ".hidden_dir"))
-		assert.False(t, containsPath(files, ".hidden_file.txt"))
-	})
-
-	t.Run("respects limit and returns truncated flag", func(t *testing.T) {
-		files, truncated, err := listDirectory(tempDir, []string{}, 2)
-		require.NoError(t, err)
-		assert.True(t, truncated)
-		assert.Len(t, files, 2)
-	})
-
-	t.Run("respects ignore patterns", func(t *testing.T) {
-		files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000)
-		require.NoError(t, err)
-		assert.False(t, truncated)
-		
-		// Check that no .txt files are included
-		for _, file := range files {
-			assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file)
-		}
-		
-		// But directories should still be included
-		containsDir := false
-		for _, file := range files {
-			if strings.Contains(file, "dir1") {
-				containsDir = true
-				break
-			}
-		}
-		assert.True(t, containsDir)
-	})
-}

internal/llm/tools/patch.go 🔗

@@ -8,12 +8,12 @@ import (
 	"path/filepath"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/diff"
-	"github.com/opencode-ai/opencode/internal/history"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/permission"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
 )
 
 type PatchParams struct {

internal/llm/tools/shell/shell.go 🔗

@@ -12,7 +12,7 @@ import (
 	"syscall"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/charmbracelet/crush/internal/config"
 )
 
 type PersistentShell struct {
@@ -61,23 +61,23 @@ func GetPersistentShell(workingDir string) *PersistentShell {
 func newPersistentShell(cwd string) *PersistentShell {
 	// Get shell configuration from config
 	cfg := config.Get()
-	
+
 	// Default to environment variable if config is not set or nil
 	var shellPath string
 	var shellArgs []string
-	
+
 	if cfg != nil {
 		shellPath = cfg.Shell.Path
 		shellArgs = cfg.Shell.Args
 	}
-	
+
 	if shellPath == "" {
 		shellPath = os.Getenv("SHELL")
 		if shellPath == "" {
 			shellPath = "/bin/bash"
 		}
 	}
-	
+
 	// Default shell args
 	if len(shellArgs) == 0 {
 		shellArgs = []string{"-l"}
@@ -149,10 +149,10 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
 	}
 
 	tempDir := os.TempDir()
-	stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano()))
-	stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano()))
-	statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano()))
-	cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano()))
+	stdoutFile := filepath.Join(tempDir, fmt.Sprintf("crush-stdout-%d", time.Now().UnixNano()))
+	stderrFile := filepath.Join(tempDir, fmt.Sprintf("crush-stderr-%d", time.Now().UnixNano()))
+	statusFile := filepath.Join(tempDir, fmt.Sprintf("crush-status-%d", time.Now().UnixNano()))
+	cwdFile := filepath.Join(tempDir, fmt.Sprintf("crush-cwd-%d", time.Now().UnixNano()))
 
 	defer func() {
 		os.Remove(stdoutFile)

internal/llm/tools/sourcegraph.go 🔗

@@ -218,7 +218,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
 	}
 
 	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("User-Agent", "opencode/1.0")
+	req.Header.Set("User-Agent", "crush/1.0")
 
 	resp, err := client.Do(req)
 	if err != nil {

internal/llm/tools/view.go 🔗

@@ -10,8 +10,8 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/lsp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/lsp"
 )
 
 type ViewParams struct {

internal/llm/tools/write.go 🔗

@@ -9,12 +9,12 @@ import (
 	"strings"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/diff"
-	"github.com/opencode-ai/opencode/internal/history"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/permission"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
 )
 
 type WriteParams struct {

internal/logging/logger.go 🔗

@@ -54,7 +54,7 @@ func RecoverPanic(name string, cleanup func()) {
 
 		// Create a timestamped panic log file
 		timestamp := time.Now().Format("20060102-150405")
-		filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
+		filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp)
 
 		file, err := os.Create(filename)
 		if err != nil {

internal/logging/writer.go 🔗

@@ -8,8 +8,8 @@ import (
 	"sync"
 	"time"
 
+	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/go-logfmt/logfmt"
-	"github.com/opencode-ai/opencode/internal/pubsub"
 )
 
 const (

internal/lsp/client.go 🔗

@@ -14,9 +14,9 @@ import (
 	"sync/atomic"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
 )
 
 type Client struct {

internal/lsp/handlers.go 🔗

@@ -3,10 +3,10 @@ package lsp
 import (
 	"encoding/json"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/lsp/protocol"
-	"github.com/opencode-ai/opencode/internal/lsp/util"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
+	"github.com/charmbracelet/crush/internal/lsp/util"
 )
 
 // Requests

internal/lsp/language.go 🔗

@@ -4,7 +4,7 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
 )
 
 func DetectLanguageID(uri string) protocol.LanguageKind {

internal/lsp/methods.go 🔗

@@ -4,7 +4,7 @@ package lsp
 import (
 	"context"
 
-	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
 )
 
 // Implementation sends a textDocument/implementation request to the LSP server.

internal/lsp/transport.go 🔗

@@ -8,8 +8,8 @@ import (
 	"io"
 	"strings"
 
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/logging"
 )
 
 // Write writes an LSP message to the given writer

internal/lsp/util/edit.go 🔗

@@ -7,7 +7,7 @@ import (
 	"sort"
 	"strings"
 
-	"github.com/opencode-ai/opencode/internal/lsp/protocol"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
 )
 
 func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {

internal/lsp/watcher/watcher.go 🔗

@@ -10,11 +10,11 @@ import (
 	"time"
 
 	"github.com/bmatcuk/doublestar/v4"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
 	"github.com/fsnotify/fsnotify"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/lsp/protocol"
 )
 
 // WorkspaceWatcher manages LSP file watching
@@ -401,7 +401,6 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
 					"watched", matched,
 					"kind", kind,
 				)
-
 			}
 
 			// Check if this path should be watched according to server registrations

internal/message/content.go 🔗

@@ -5,7 +5,7 @@ import (
 	"slices"
 	"time"
 
-	"github.com/opencode-ai/opencode/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/llm/models"
 )
 
 type MessageRole string

internal/message/message.go 🔗

@@ -7,10 +7,10 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/google/uuid"
-	"github.com/opencode-ai/opencode/internal/db"
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/pubsub"
 )
 
 type CreateMessageParams struct {
@@ -274,7 +274,6 @@ func unmarshallParts(data []byte) ([]ContentPart, error) {
 		default:
 			return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
 		}
-
 	}
 
 	return parts, nil

internal/permission/permission.go 🔗

@@ -6,9 +6,9 @@ import (
 	"slices"
 	"sync"
 
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/google/uuid"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/pubsub"
 )
 
 var ErrorPermissionDenied = errors.New("permission denied")
@@ -34,7 +34,7 @@ type PermissionRequest struct {
 
 type Service interface {
 	pubsub.Suscriber[PermissionRequest]
-	GrantPersistant(permission PermissionRequest)
+	GrantPersistent(permission PermissionRequest)
 	Grant(permission PermissionRequest)
 	Deny(permission PermissionRequest)
 	Request(opts CreatePermissionRequest) bool
@@ -49,7 +49,7 @@ type permissionService struct {
 	autoApproveSessions []string
 }
 
-func (s *permissionService) GrantPersistant(permission PermissionRequest) {
+func (s *permissionService) GrantPersistent(permission PermissionRequest) {
 	respCh, ok := s.pendingRequests.Load(permission.ID)
 	if ok {
 		respCh.(chan bool) <- true

internal/session/session.go 🔗

@@ -4,9 +4,9 @@ import (
 	"context"
 	"database/sql"
 
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/google/uuid"
-	"github.com/opencode-ai/opencode/internal/db"
-	"github.com/opencode-ai/opencode/internal/pubsub"
 )
 
 type Session struct {

internal/tui/components/anim/anim.go 🔗

@@ -0,0 +1,307 @@
+package anim
+
+import (
+	"fmt"
+	"image/color"
+	"math/rand/v2"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/v2/spinner"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/google/uuid"
+	"github.com/lucasb-eyer/go-colorful"
+)
+
+const (
+	charCyclingFPS  = time.Second / 22
+	colorCycleFPS   = time.Second / 5
+	maxCyclingChars = 120
+)
+
+var charRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
+
+type charState int
+
+const (
+	charInitialState charState = iota
+	charCyclingState
+	charEndOfLifeState
+)
+
+// cyclingChar is a single animated character.
+type cyclingChar struct {
+	finalValue   rune // if < 0 cycle forever
+	currentValue rune
+	initialDelay time.Duration
+	lifetime     time.Duration
+}
+
+func (c cyclingChar) randomRune() rune {
+	return (charRunes)[rand.IntN(len(charRunes))] //nolint:gosec
+}
+
+func (c cyclingChar) state(start time.Time) charState {
+	now := time.Now()
+	if now.Before(start.Add(c.initialDelay)) {
+		return charInitialState
+	}
+	if c.finalValue > 0 && now.After(start.Add(c.initialDelay)) {
+		return charEndOfLifeState
+	}
+	return charCyclingState
+}
+
+type StepCharsMsg struct {
+	id string
+}
+
+func stepChars(id string) tea.Cmd {
+	return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
+		return StepCharsMsg{id}
+	})
+}
+
+type ColorCycleMsg struct {
+	id string
+}
+
+func cycleColors(id string) tea.Cmd {
+	return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
+		return ColorCycleMsg{id}
+	})
+}
+
+type Animation interface {
+	util.Model
+	ID() string
+}
+
+// anim is the model that manages the animation that displays while the
+// output is being generated.
+type anim struct {
+	start           time.Time
+	cyclingChars    []cyclingChar
+	labelChars      []cyclingChar
+	ramp            []lipgloss.Style
+	label           []rune
+	ellipsis        spinner.Model
+	ellipsisStarted bool
+	id              string
+}
+
+type animOption func(*anim)
+
+func WithId(id string) animOption {
+	return func(a *anim) {
+		a.id = id
+	}
+}
+
+func New(cyclingCharsSize uint, label string, opts ...animOption) Animation {
+	// #nosec G115
+	n := min(int(cyclingCharsSize), maxCyclingChars)
+
+	gap := " "
+	if n == 0 {
+		gap = ""
+	}
+
+	id := uuid.New()
+	c := anim{
+		start:    time.Now(),
+		label:    []rune(gap + label),
+		ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
+		id:       id.String(),
+	}
+
+	for _, opt := range opts {
+		opt(&c)
+	}
+
+	// If we're in truecolor mode (and there are enough cycling characters)
+	// color the cycling characters with a gradient ramp.
+	const minRampSize = 3
+	if n >= minRampSize {
+		// Note: double capacity for color cycling as we'll need to reverse and
+		// append the ramp for seamless transitions.
+		c.ramp = make([]lipgloss.Style, n, n*2) //nolint:mnd
+		ramp := makeGradientRamp(n)
+		for i, color := range ramp {
+			c.ramp[i] = lipgloss.NewStyle().Foreground(color)
+		}
+		c.ramp = append(c.ramp, reverse(c.ramp)...) // reverse and append for color cycling
+	}
+
+	makeDelay := func(a int32, b time.Duration) time.Duration {
+		return time.Duration(rand.Int32N(a)) * (time.Millisecond * b) //nolint:gosec
+	}
+
+	makeInitialDelay := func() time.Duration {
+		return makeDelay(8, 60) //nolint:mnd
+	}
+
+	// Initial characters that cycle forever.
+	c.cyclingChars = make([]cyclingChar, n)
+
+	for i := range n {
+		c.cyclingChars[i] = cyclingChar{
+			finalValue:   -1, // cycle forever
+			initialDelay: makeInitialDelay(),
+		}
+	}
+
+	// Label text that only cycles for a little while.
+	c.labelChars = make([]cyclingChar, len(c.label))
+
+	for i, r := range c.label {
+		c.labelChars[i] = cyclingChar{
+			finalValue:   r,
+			initialDelay: makeInitialDelay(),
+			lifetime:     makeDelay(5, 180), //nolint:mnd
+		}
+	}
+
+	return c
+}
+
+// Init initializes the animation.
+func (a anim) Init() tea.Cmd {
+	return tea.Batch(stepChars(a.id), cycleColors(a.id))
+}
+
+// Update handles messages.
+func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	switch msg := msg.(type) {
+	case StepCharsMsg:
+		if msg.id != a.id {
+			return a, nil
+		}
+		a.updateChars(&a.cyclingChars)
+		a.updateChars(&a.labelChars)
+
+		if !a.ellipsisStarted {
+			var eol int
+			for _, c := range a.labelChars {
+				if c.state(a.start) == charEndOfLifeState {
+					eol++
+				}
+			}
+			if eol == len(a.label) {
+				// If our entire label has reached end of life, start the
+				// ellipsis "spinner" after a short pause.
+				a.ellipsisStarted = true
+				cmd = tea.Tick(time.Millisecond*220, func(time.Time) tea.Msg { //nolint:mnd
+					return a.ellipsis.Tick()
+				})
+			}
+		}
+
+		return a, tea.Batch(stepChars(a.id), cmd)
+	case ColorCycleMsg:
+		if msg.id != a.id {
+			return a, nil
+		}
+		const minColorCycleSize = 2
+		if len(a.ramp) < minColorCycleSize {
+			return a, nil
+		}
+		a.ramp = append(a.ramp[1:], a.ramp[0])
+		return a, cycleColors(a.id)
+	case spinner.TickMsg:
+		var cmd tea.Cmd
+		a.ellipsis, cmd = a.ellipsis.Update(msg)
+		return a, cmd
+	default:
+		return a, nil
+	}
+}
+
+func (a anim) ID() string {
+	return a.id
+}
+
+func (a *anim) updateChars(chars *[]cyclingChar) {
+	charSlice := *chars // dereference to avoid repeated pointer access
+	for i, c := range charSlice {
+		switch c.state(a.start) {
+		case charInitialState:
+			charSlice[i].currentValue = '.'
+		case charCyclingState:
+			charSlice[i].currentValue = c.randomRune()
+		case charEndOfLifeState:
+			charSlice[i].currentValue = c.finalValue
+		}
+	}
+}
+
+// View renders the animation.
+func (a anim) View() tea.View {
+	var (
+		t = styles.CurrentTheme()
+		b strings.Builder
+	)
+
+	// Pre-allocate builder capacity to avoid reallocations.
+	// Estimate: cycling chars + label chars + ellipsis + style overhead.
+	const (
+		bytesPerChar = 20 // ANSI styling
+		bufferSize   = 50 // ellipsis and safety margin
+	)
+	estimatedCap := len(a.cyclingChars)*bytesPerChar + len(a.labelChars)*bytesPerChar + bufferSize
+	b.Grow(estimatedCap)
+
+	for i, c := range a.cyclingChars {
+		if len(a.ramp) > i {
+			b.WriteString(a.ramp[i].Render(string(c.currentValue)))
+			continue
+		}
+		b.WriteRune(c.currentValue)
+	}
+
+	if len(a.labelChars) > 1 {
+		textStyle := t.S().Text
+		for _, c := range a.labelChars {
+			b.WriteString(
+				textStyle.Render(string(c.currentValue)),
+			)
+		}
+		b.WriteString(textStyle.Render(a.ellipsis.View()))
+	}
+
+	return tea.NewView(b.String())
+}
+
+func GetColor(c color.Color) string {
+	rgba := color.RGBAModel.Convert(c).(color.RGBA)
+	return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
+}
+
+func makeGradientRamp(length int) []color.Color {
+	t := styles.CurrentTheme()
+	startColor := GetColor(t.Primary)
+	endColor := GetColor(t.Secondary)
+	var (
+		c        = make([]color.Color, length)
+		start, _ = colorful.Hex(startColor)
+		end, _   = colorful.Hex(endColor)
+	)
+	for i := range length {
+		step := start.BlendLuv(end, float64(i)/float64(length))
+		c[i] = lipgloss.Color(step.Hex())
+	}
+	return c
+}
+
+func reverse[T any](in []T) []T {
+	out := make([]T, len(in))
+	copy(out, in[:])
+	for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
+		out[i], out[j] = out[j], out[i]
+	}
+	return out
+}

internal/tui/components/chat/chat.go 🔗

@@ -1,17 +1,20 @@
 package chat
 
 import (
-	"fmt"
-	"sort"
-
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/version"
+	"context"
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type SendMsg struct {
@@ -23,119 +26,472 @@ type SessionSelectedMsg = session.Session
 
 type SessionClearedMsg struct{}
 
-type EditorFocusMsg bool
+const (
+	NotFound = -1
+)
+
+// MessageListCmp represents a component that displays a list of chat messages
+// with support for real-time updates and session management.
+type MessageListCmp interface {
+	util.Model
+	layout.Sizeable
+	layout.Focusable
+}
+
+// messageListCmp implements MessageListCmp, providing a virtualized list
+// of chat messages with support for tool calls, real-time updates, and
+// session switching.
+type messageListCmp struct {
+	app              *app.App
+	width, height    int
+	session          session.Session
+	listCmp          list.ListModel
+	previousSelected int // Last selected item index for restoring focus
+
+	lastUserMessageTime int64
+}
+
+// NewMessagesListCmp creates a new message list component with custom keybindings
+// and reverse ordering (newest messages at bottom).
+func NewMessagesListCmp(app *app.App) MessageListCmp {
+	defaultKeymaps := list.DefaultKeyMap()
+	listCmp := list.New(
+		list.WithGapSize(1),
+		list.WithReverse(true),
+		list.WithKeyMap(defaultKeymaps),
+	)
+	return &messageListCmp{
+		app:              app,
+		listCmp:          listCmp,
+		previousSelected: list.NoSelection,
+	}
+}
+
+// Init initializes the component (no initialization needed).
+func (m *messageListCmp) Init() tea.Cmd {
+	return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
+}
+
+// Update handles incoming messages and updates the component state.
+func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case SessionSelectedMsg:
+		if msg.ID != m.session.ID {
+			cmd := m.SetSession(msg)
+			return m, cmd
+		}
+		return m, nil
+	case SessionClearedMsg:
+		m.session = session.Session{}
+		return m, m.listCmp.SetItems([]util.Model{})
 
-func header(width int) string {
-	return lipgloss.JoinVertical(
-		lipgloss.Top,
-		logo(width),
-		repo(width),
-		"",
-		cwd(width),
+	case pubsub.Event[message.Message]:
+		cmd := m.handleMessageEvent(msg)
+		return m, cmd
+	default:
+		var cmds []tea.Cmd
+		u, cmd := m.listCmp.Update(msg)
+		m.listCmp = u.(list.ListModel)
+		cmds = append(cmds, cmd)
+		return m, tea.Batch(cmds...)
+	}
+}
+
+// View renders the message list or an initial screen if empty.
+func (m *messageListCmp) View() tea.View {
+	return tea.NewView(
+		lipgloss.JoinVertical(
+			lipgloss.Left,
+			m.listCmp.View().String(),
+		),
+	)
+}
+
+// handleChildSession handles messages from child sessions (agent tools).
+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
+	var cmds []tea.Cmd
+	if len(event.Payload.ToolCalls()) == 0 {
+		return nil
+	}
+	items := m.listCmp.Items()
+	toolCallInx := NotFound
+	var toolCall messages.ToolCallCmp
+	for i := len(items) - 1; i >= 0; i-- {
+		if msg, ok := items[i].(messages.ToolCallCmp); ok {
+			if msg.GetToolCall().ID == event.Payload.SessionID {
+				toolCallInx = i
+				toolCall = msg
+			}
+		}
+	}
+	if toolCallInx == NotFound {
+		return nil
+	}
+	nestedToolCalls := toolCall.GetNestedToolCalls()
+	for _, tc := range event.Payload.ToolCalls() {
+		found := false
+		for existingInx, existingTC := range nestedToolCalls {
+			if existingTC.GetToolCall().ID == tc.ID {
+				nestedToolCalls[existingInx].SetToolCall(tc)
+				found = true
+				break
+			}
+		}
+		if !found {
+			nestedCall := messages.NewToolCallCmp(
+				event.Payload.ID,
+				tc,
+				messages.WithToolCallNested(true),
+			)
+			cmds = append(cmds, nestedCall.Init())
+			nestedToolCalls = append(
+				nestedToolCalls,
+				nestedCall,
+			)
+		}
+	}
+	toolCall.SetNestedToolCalls(nestedToolCalls)
+	m.listCmp.UpdateItem(
+		toolCallInx,
+		toolCall,
 	)
+	return tea.Batch(cmds...)
 }
 
-func lspsConfigured(width int) string {
-	cfg := config.Get()
-	title := "LSP Configuration"
-	title = ansi.Truncate(title, width, "…")
-
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	lsps := baseStyle.
-		Width(width).
-		Foreground(t.Primary()).
-		Bold(true).
-		Render(title)
-
-	// Get LSP names and sort them for consistent ordering
-	var lspNames []string
-	for name := range cfg.LSP {
-		lspNames = append(lspNames, name)
-	}
-	sort.Strings(lspNames)
-
-	var lspViews []string
-	for _, name := range lspNames {
-		lsp := cfg.LSP[name]
-		lspName := baseStyle.
-			Foreground(t.Text()).
-			Render(fmt.Sprintf("• %s", name))
-
-		cmd := lsp.Command
-		cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
-
-		lspPath := baseStyle.
-			Foreground(t.TextMuted()).
-			Render(fmt.Sprintf(" (%s)", cmd))
-
-		lspViews = append(lspViews,
-			baseStyle.
-				Width(width).
-				Render(
-					lipgloss.JoinHorizontal(
-						lipgloss.Left,
-						lspName,
-						lspPath,
-					),
-				),
+// handleMessageEvent processes different types of message events (created/updated).
+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
+	switch event.Type {
+	case pubsub.CreatedEvent:
+		if event.Payload.SessionID != m.session.ID {
+			return m.handleChildSession(event)
+		}
+		if m.messageExists(event.Payload.ID) {
+			return nil
+		}
+		return m.handleNewMessage(event.Payload)
+	case pubsub.UpdatedEvent:
+		if event.Payload.SessionID != m.session.ID {
+			return m.handleChildSession(event)
+		}
+		return m.handleUpdateAssistantMessage(event.Payload)
+	}
+	return nil
+}
+
+// messageExists checks if a message with the given ID already exists in the list.
+func (m *messageListCmp) messageExists(messageID string) bool {
+	items := m.listCmp.Items()
+	// Search backwards as new messages are more likely to be at the end
+	for i := len(items) - 1; i >= 0; i-- {
+		if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
+			return true
+		}
+	}
+	return false
+}
+
+// handleNewMessage routes new messages to appropriate handlers based on role.
+func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
+	switch msg.Role {
+	case message.User:
+		return m.handleNewUserMessage(msg)
+	case message.Assistant:
+		return m.handleNewAssistantMessage(msg)
+	case message.Tool:
+		return m.handleToolMessage(msg)
+	}
+	return nil
+}
+
+// handleNewUserMessage adds a new user message to the list and updates the timestamp.
+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
+	m.lastUserMessageTime = msg.CreatedAt
+	return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
+}
+
+// handleToolMessage updates existing tool calls with their results.
+func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
+	items := m.listCmp.Items()
+	for _, tr := range msg.ToolResults() {
+		if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
+			toolCall := items[toolCallIndex].(messages.ToolCallCmp)
+			toolCall.SetToolResult(tr)
+			m.listCmp.UpdateItem(toolCallIndex, toolCall)
+		}
+	}
+	return nil
+}
+
+// findToolCallByID searches for a tool call with the specified ID.
+// Returns the index if found, NotFound otherwise.
+func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
+	// Search backwards as tool calls are more likely to be recent
+	for i := len(items) - 1; i >= 0; i-- {
+		if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
+			return i
+		}
+	}
+	return NotFound
+}
+
+// handleUpdateAssistantMessage processes updates to assistant messages,
+// managing both message content and associated tool calls.
+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	items := m.listCmp.Items()
+
+	// Find existing assistant message and tool calls for this message
+	assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
+
+	// Handle assistant message content
+	if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	// Handle tool calls
+	if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	return tea.Batch(cmds...)
+}
+
+// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
+	assistantIndex := NotFound
+	toolCalls := make(map[int]messages.ToolCallCmp)
+
+	// Search backwards as messages are more likely to be at the end
+	for i := len(items) - 1; i >= 0; i-- {
+		item := items[i]
+		if asMsg, ok := item.(messages.MessageCmp); ok {
+			if asMsg.GetMessage().ID == messageID {
+				assistantIndex = i
+			}
+		} else if tc, ok := item.(messages.ToolCallCmp); ok {
+			if tc.ParentMessageId() == messageID {
+				toolCalls[i] = tc
+			}
+		}
+	}
+
+	return assistantIndex, toolCalls
+}
+
+// updateAssistantMessageContent updates or removes the assistant message based on content.
+func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
+	if assistantIndex == NotFound {
+		return nil
+	}
+
+	shouldShowMessage := m.shouldShowAssistantMessage(msg)
+	hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
+
+	if shouldShowMessage {
+		m.listCmp.UpdateItem(
+			assistantIndex,
+			messages.NewMessageCmp(
+				msg,
+				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+			),
 		)
+	} else if hasToolCallsOnly {
+		m.listCmp.DeleteItem(assistantIndex)
+	}
+
+	return nil
+}
+
+// shouldShowAssistantMessage determines if an assistant message should be displayed.
+func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
+	return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
+}
+
+// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
+func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
+	var cmds []tea.Cmd
+
+	for _, tc := range msg.ToolCalls() {
+		if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+
+	return tea.Batch(cmds...)
+}
+
+// updateOrAddToolCall updates an existing tool call or adds a new one.
+func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
+	// Try to find existing tool call
+	for index, existingTC := range existingToolCalls {
+		if tc.ID == existingTC.GetToolCall().ID {
+			existingTC.SetToolCall(tc)
+			m.listCmp.UpdateItem(index, existingTC)
+			return nil
+		}
 	}
 
-	return baseStyle.
-		Width(width).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				lsps,
-				lipgloss.JoinVertical(
-					lipgloss.Left,
-					lspViews...,
-				),
+	// Add new tool call if not found
+	return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
+}
+
+// handleNewAssistantMessage processes new assistant messages and their tool calls.
+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+
+	// Add assistant message if it should be displayed
+	if m.shouldShowAssistantMessage(msg) {
+		cmd := m.listCmp.AppendItem(
+			messages.NewMessageCmp(
+				msg,
+				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
 			),
 		)
+		cmds = append(cmds, cmd)
+	}
+
+	// Add tool calls
+	for _, tc := range msg.ToolCalls() {
+		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
+		cmds = append(cmds, cmd)
+	}
+
+	return tea.Batch(cmds...)
 }
 
-func logo(width int) string {
-	logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	versionText := baseStyle.
-		Foreground(t.TextMuted()).
-		Render(version.Version)
-
-	return baseStyle.
-		Bold(true).
-		Width(width).
-		Render(
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				logo,
-				" ",
-				versionText,
+// SetSession loads and displays messages for a new session.
+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
+	if m.session.ID == session.ID {
+		return nil
+	}
+
+	m.session = session
+	sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
+	if err != nil {
+		return util.ReportError(err)
+	}
+
+	if len(sessionMessages) == 0 {
+		return m.listCmp.SetItems([]util.Model{})
+	}
+
+	// Initialize with first message timestamp
+	m.lastUserMessageTime = sessionMessages[0].CreatedAt
+
+	// Build tool result map for efficient lookup
+	toolResultMap := m.buildToolResultMap(sessionMessages)
+
+	// Convert messages to UI components
+	uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
+
+	return m.listCmp.SetItems(uiMessages)
+}
+
+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
+func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
+	toolResultMap := make(map[string]message.ToolResult)
+	for _, msg := range messages {
+		for _, tr := range msg.ToolResults() {
+			toolResultMap[tr.ToolCallID] = tr
+		}
+	}
+	return toolResultMap
+}
+
+// convertMessagesToUI converts database messages to UI components.
+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
+	uiMessages := make([]util.Model, 0)
+
+	for _, msg := range sessionMessages {
+		switch msg.Role {
+		case message.User:
+			m.lastUserMessageTime = msg.CreatedAt
+			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
+		case message.Assistant:
+			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
+		}
+	}
+
+	return uiMessages
+}
+
+// convertAssistantMessage converts an assistant message and its tool calls to UI components.
+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
+	var uiMessages []util.Model
+
+	// Add assistant message if it should be displayed
+	if m.shouldShowAssistantMessage(msg) {
+		uiMessages = append(
+			uiMessages,
+			messages.NewMessageCmp(
+				msg,
+				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
 			),
 		)
+	}
+
+	// Add tool calls with their results and status
+	for _, tc := range msg.ToolCalls() {
+		options := m.buildToolCallOptions(tc, msg, toolResultMap)
+		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
+		// If this tool call is the agent tool, fetch nested tool calls
+		if tc.Name == agent.AgentToolName {
+			nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
+			nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
+			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
+			for _, nestedMsg := range nestedUIMessages {
+				if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
+					toolCall.SetIsNested(true)
+					nestedToolCalls = append(nestedToolCalls, toolCall)
+				}
+			}
+			uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
+		}
+	}
+
+	return uiMessages
 }
 
-func repo(width int) string {
-	repo := "https://github.com/opencode-ai/opencode"
-	t := theme.CurrentTheme()
+// buildToolCallOptions creates options for tool call components based on results and status.
+func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
+	var options []messages.ToolCallOption
+
+	// Add tool result if available
+	if tr, ok := toolResultMap[tc.ID]; ok {
+		options = append(options, messages.WithToolCallResult(tr))
+	}
+
+	// Add cancelled status if applicable
+	if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
+		options = append(options, messages.WithToolCallCancelled())
+	}
 
-	return styles.BaseStyle().
-		Foreground(t.TextMuted()).
-		Width(width).
-		Render(repo)
+	return options
 }
 
-func cwd(width int) string {
-	cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
-	t := theme.CurrentTheme()
+// GetSize returns the current width and height of the component.
+func (m *messageListCmp) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+// SetSize updates the component dimensions and propagates to the list component.
+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	m.height = height - 1
+	return m.listCmp.SetSize(width, height-1)
+}
 
-	return styles.BaseStyle().
-		Foreground(t.TextMuted()).
-		Width(width).
-		Render(cwd)
+// Blur implements MessageListCmp.
+func (m *messageListCmp) Blur() tea.Cmd {
+	return m.listCmp.Blur()
 }
 
+// Focus implements MessageListCmp.
+func (m *messageListCmp) Focus() tea.Cmd {
+	return m.listCmp.Focus()
+}
+
+// IsFocused implements MessageListCmp.
+func (m *messageListCmp) IsFocused() bool {
+	return m.listCmp.IsFocused()
+}

internal/tui/components/chat/editor.go 🔗

@@ -1,318 +0,0 @@
-package chat
-
-import (
-	"fmt"
-	"os"
-	"os/exec"
-	"slices"
-	"strings"
-	"unicode"
-
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/textarea"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type editorCmp struct {
-	width       int
-	height      int
-	app         *app.App
-	session     session.Session
-	textarea    textarea.Model
-	attachments []message.Attachment
-	deleteMode  bool
-}
-
-type EditorKeyMaps struct {
-	Send       key.Binding
-	OpenEditor key.Binding
-}
-
-type bluredEditorKeyMaps struct {
-	Send       key.Binding
-	Focus      key.Binding
-	OpenEditor key.Binding
-}
-type DeleteAttachmentKeyMaps struct {
-	AttachmentDeleteMode key.Binding
-	Escape               key.Binding
-	DeleteAllAttachments key.Binding
-}
-
-var editorMaps = EditorKeyMaps{
-	Send: key.NewBinding(
-		key.WithKeys("enter", "ctrl+s"),
-		key.WithHelp("enter", "send message"),
-	),
-	OpenEditor: key.NewBinding(
-		key.WithKeys("ctrl+e"),
-		key.WithHelp("ctrl+e", "open editor"),
-	),
-}
-
-var DeleteKeyMaps = DeleteAttachmentKeyMaps{
-	AttachmentDeleteMode: key.NewBinding(
-		key.WithKeys("ctrl+r"),
-		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
-	),
-	Escape: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "cancel delete mode"),
-	),
-	DeleteAllAttachments: key.NewBinding(
-		key.WithKeys("r"),
-		key.WithHelp("ctrl+r+r", "delete all attchments"),
-	),
-}
-
-const (
-	maxAttachments = 5
-)
-
-func (m *editorCmp) openEditor() tea.Cmd {
-	editor := os.Getenv("EDITOR")
-	if editor == "" {
-		editor = "nvim"
-	}
-
-	tmpfile, err := os.CreateTemp("", "msg_*.md")
-	if err != nil {
-		return util.ReportError(err)
-	}
-	tmpfile.Close()
-	c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
-	c.Stdin = os.Stdin
-	c.Stdout = os.Stdout
-	c.Stderr = os.Stderr
-	return tea.ExecProcess(c, func(err error) tea.Msg {
-		if err != nil {
-			return util.ReportError(err)
-		}
-		content, err := os.ReadFile(tmpfile.Name())
-		if err != nil {
-			return util.ReportError(err)
-		}
-		if len(content) == 0 {
-			return util.ReportWarn("Message is empty")
-		}
-		os.Remove(tmpfile.Name())
-		attachments := m.attachments
-		m.attachments = nil
-		return SendMsg{
-			Text:        string(content),
-			Attachments: attachments,
-		}
-	})
-}
-
-func (m *editorCmp) Init() tea.Cmd {
-	return textarea.Blink
-}
-
-func (m *editorCmp) send() tea.Cmd {
-	if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
-		return util.ReportWarn("Agent is working, please wait...")
-	}
-
-	value := m.textarea.Value()
-	m.textarea.Reset()
-	attachments := m.attachments
-
-	m.attachments = nil
-	if value == "" {
-		return nil
-	}
-	return tea.Batch(
-		util.CmdHandler(SendMsg{
-			Text:        value,
-			Attachments: attachments,
-		}),
-	)
-}
-
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmd tea.Cmd
-	switch msg := msg.(type) {
-	case dialog.ThemeChangedMsg:
-		m.textarea = CreateTextArea(&m.textarea)
-	case dialog.CompletionSelectedMsg:
-		existingValue := m.textarea.Value()
-		modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
-
-		m.textarea.SetValue(modifiedValue)
-		return m, nil
-	case SessionSelectedMsg:
-		if msg.ID != m.session.ID {
-			m.session = msg
-		}
-		return m, nil
-	case dialog.AttachmentAddedMsg:
-		if len(m.attachments) >= maxAttachments {
-			logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
-			return m, cmd
-		}
-		m.attachments = append(m.attachments, msg.Attachment)
-	case tea.KeyMsg:
-		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
-			m.deleteMode = true
-			return m, nil
-		}
-		if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
-			m.deleteMode = false
-			m.attachments = nil
-			return m, nil
-		}
-		if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
-			num := int(msg.Runes[0] - '0')
-			m.deleteMode = false
-			if num < 10 && len(m.attachments) > num {
-				if num == 0 {
-					m.attachments = m.attachments[num+1:]
-				} else {
-					m.attachments = slices.Delete(m.attachments, num, num+1)
-				}
-				return m, nil
-			}
-		}
-		if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
-			key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
-			return m, nil
-		}
-		if key.Matches(msg, editorMaps.OpenEditor) {
-			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
-				return m, util.ReportWarn("Agent is working, please wait...")
-			}
-			return m, m.openEditor()
-		}
-		if key.Matches(msg, DeleteKeyMaps.Escape) {
-			m.deleteMode = false
-			return m, nil
-		}
-		// Hanlde Enter key
-		if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
-			value := m.textarea.Value()
-			if len(value) > 0 && value[len(value)-1] == '\\' {
-				// If the last character is a backslash, remove it and add a newline
-				m.textarea.SetValue(value[:len(value)-1] + "\n")
-				return m, nil
-			} else {
-				// Otherwise, send the message
-				return m, m.send()
-			}
-		}
-
-	}
-	m.textarea, cmd = m.textarea.Update(msg)
-	return m, cmd
-}
-
-func (m *editorCmp) View() string {
-	t := theme.CurrentTheme()
-
-	// Style the prompt with theme colors
-	style := lipgloss.NewStyle().
-		Padding(0, 0, 0, 1).
-		Bold(true).
-		Foreground(t.Primary())
-
-	if len(m.attachments) == 0 {
-		return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
-	}
-	m.textarea.SetHeight(m.height - 1)
-	return lipgloss.JoinVertical(lipgloss.Top,
-		m.attachmentsContent(),
-		lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
-			m.textarea.View()),
-	)
-}
-
-func (m *editorCmp) SetSize(width, height int) tea.Cmd {
-	m.width = width
-	m.height = height
-	m.textarea.SetWidth(width - 3) // account for the prompt and padding right
-	m.textarea.SetHeight(height)
-	m.textarea.SetWidth(width)
-	return nil
-}
-
-func (m *editorCmp) GetSize() (int, int) {
-	return m.textarea.Width(), m.textarea.Height()
-}
-
-func (m *editorCmp) attachmentsContent() string {
-	var styledAttachments []string
-	t := theme.CurrentTheme()
-	attachmentStyles := styles.BaseStyle().
-		MarginLeft(1).
-		Background(t.TextMuted()).
-		Foreground(t.Text())
-	for i, attachment := range m.attachments {
-		var filename string
-		if len(attachment.FileName) > 10 {
-			filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
-		} else {
-			filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
-		}
-		if m.deleteMode {
-			filename = fmt.Sprintf("%d%s", i, filename)
-		}
-		styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
-	}
-	content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
-	return content
-}
-
-func (m *editorCmp) BindingKeys() []key.Binding {
-	bindings := []key.Binding{}
-	bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
-	bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
-	return bindings
-}
-
-func CreateTextArea(existing *textarea.Model) textarea.Model {
-	t := theme.CurrentTheme()
-	bgColor := t.Background()
-	textColor := t.Text()
-	textMutedColor := t.TextMuted()
-
-	ta := textarea.New()
-	ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-	ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
-	ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
-	ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-	ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-	ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
-	ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
-	ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-
-	ta.Prompt = " "
-	ta.ShowLineNumbers = false
-	ta.CharLimit = -1
-
-	if existing != nil {
-		ta.SetValue(existing.Value())
-		ta.SetWidth(existing.Width())
-		ta.SetHeight(existing.Height())
-	}
-
-	ta.Focus()
-	return ta
-}
-
-func NewEditorCmp(app *app.App) tea.Model {
-	ta := CreateTextArea(nil)
-	return &editorCmp{
-		app:      app,
-		textarea: ta,
-	}
-}

internal/tui/components/chat/editor/editor.go 🔗

@@ -0,0 +1,396 @@
+package editor
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"slices"
+	"strings"
+	"unicode"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/textarea"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/fileutil"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/chat"
+	"github.com/charmbracelet/crush/internal/tui/components/completions"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+type FileCompletionItem struct {
+	Path string // The file path
+}
+
+type editorCmp struct {
+	width       int
+	height      int
+	x, y        int
+	app         *app.App
+	session     session.Session
+	textarea    textarea.Model
+	attachments []message.Attachment
+	deleteMode  bool
+
+	keyMap EditorKeyMap
+
+	// File path completions
+	currentQuery          string
+	completionsStartIndex int
+	isCompletionsOpen     bool
+}
+
+type DeleteAttachmentKeyMaps struct {
+	AttachmentDeleteMode key.Binding
+	Escape               key.Binding
+	DeleteAllAttachments key.Binding
+}
+
+var DeleteKeyMaps = DeleteAttachmentKeyMaps{
+	AttachmentDeleteMode: key.NewBinding(
+		key.WithKeys("ctrl+r"),
+		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
+	),
+	Escape: key.NewBinding(
+		key.WithKeys("esc"),
+		key.WithHelp("esc", "cancel delete mode"),
+	),
+	DeleteAllAttachments: key.NewBinding(
+		key.WithKeys("r"),
+		key.WithHelp("ctrl+r+r", "delete all attchments"),
+	),
+}
+
+const (
+	maxAttachments = 5
+)
+
+func (m *editorCmp) openEditor() tea.Cmd {
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		editor = "nvim"
+	}
+
+	tmpfile, err := os.CreateTemp("", "msg_*.md")
+	if err != nil {
+		return util.ReportError(err)
+	}
+	tmpfile.Close()
+	c := exec.Command(editor, tmpfile.Name())
+	c.Stdin = os.Stdin
+	c.Stdout = os.Stdout
+	c.Stderr = os.Stderr
+	return tea.ExecProcess(c, func(err error) tea.Msg {
+		if err != nil {
+			return util.ReportError(err)
+		}
+		content, err := os.ReadFile(tmpfile.Name())
+		if err != nil {
+			return util.ReportError(err)
+		}
+		if len(content) == 0 {
+			return util.ReportWarn("Message is empty")
+		}
+		os.Remove(tmpfile.Name())
+		attachments := m.attachments
+		m.attachments = nil
+		return chat.SendMsg{
+			Text:        string(content),
+			Attachments: attachments,
+		}
+	})
+}
+
+func (m *editorCmp) Init() tea.Cmd {
+	return nil
+}
+
+func (m *editorCmp) send() tea.Cmd {
+	if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+		return util.ReportWarn("Agent is working, please wait...")
+	}
+
+	value := m.textarea.Value()
+	m.textarea.Reset()
+	attachments := m.attachments
+
+	m.attachments = nil
+	if value == "" {
+		return nil
+	}
+	return tea.Batch(
+		util.CmdHandler(chat.SendMsg{
+			Text:        value,
+			Attachments: attachments,
+		}),
+	)
+}
+
+func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	var cmds []tea.Cmd
+	switch msg := msg.(type) {
+	case chat.SessionSelectedMsg:
+		if msg.ID != m.session.ID {
+			m.session = msg
+		}
+		return m, nil
+	case filepicker.FilePickedMsg:
+		if len(m.attachments) >= maxAttachments {
+			logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
+			return m, cmd
+		}
+		m.attachments = append(m.attachments, msg.Attachment)
+		return m, nil
+	case completions.CompletionsClosedMsg:
+		m.isCompletionsOpen = false
+		m.currentQuery = ""
+		m.completionsStartIndex = 0
+	case completions.SelectCompletionMsg:
+		if !m.isCompletionsOpen {
+			return m, nil
+		}
+		if item, ok := msg.Value.(FileCompletionItem); ok {
+			// If the selected item is a file, insert its path into the textarea
+			value := m.textarea.Value()
+			value = value[:m.completionsStartIndex]
+			if len(value) > 0 && value[len(value)-1] != ' ' {
+				value += " "
+			}
+			value += item.Path
+			m.textarea.SetValue(value)
+			m.isCompletionsOpen = false
+			m.currentQuery = ""
+			m.completionsStartIndex = 0
+			return m, nil
+		}
+	case tea.KeyPressMsg:
+		switch {
+		// Completions
+		case msg.String() == "/" && !m.isCompletionsOpen:
+			m.isCompletionsOpen = true
+			m.currentQuery = ""
+			cmds = append(cmds, m.startCompletions)
+			m.completionsStartIndex = len(m.textarea.Value())
+		case msg.String() == "space" && m.isCompletionsOpen:
+			m.isCompletionsOpen = false
+			m.currentQuery = ""
+			m.completionsStartIndex = 0
+			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
+		case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
+			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
+		case msg.String() == "backspace" && m.isCompletionsOpen:
+			if len(m.currentQuery) > 0 {
+				m.currentQuery = m.currentQuery[:len(m.currentQuery)-1]
+				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
+					Query: m.currentQuery,
+				}))
+			} else {
+				m.isCompletionsOpen = false
+				m.currentQuery = ""
+				m.completionsStartIndex = 0
+				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
+			}
+		default:
+			if m.isCompletionsOpen {
+				m.currentQuery += msg.String()
+				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
+					Query: m.currentQuery,
+				}))
+			}
+		}
+		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
+			m.deleteMode = true
+			return m, nil
+		}
+		if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
+			m.deleteMode = false
+			m.attachments = nil
+			return m, nil
+		}
+		rune := msg.Code
+		if m.deleteMode && unicode.IsDigit(rune) {
+			num := int(rune - '0')
+			m.deleteMode = false
+			if num < 10 && len(m.attachments) > num {
+				if num == 0 {
+					m.attachments = m.attachments[num+1:]
+				} else {
+					m.attachments = slices.Delete(m.attachments, num, num+1)
+				}
+				return m, nil
+			}
+		}
+		if key.Matches(msg, m.keyMap.OpenEditor) {
+			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+				return m, util.ReportWarn("Agent is working, please wait...")
+			}
+			return m, m.openEditor()
+		}
+		if key.Matches(msg, DeleteKeyMaps.Escape) {
+			m.deleteMode = false
+			return m, nil
+		}
+		// Hanlde Enter key
+		if m.textarea.Focused() && key.Matches(msg, m.keyMap.Send) {
+			value := m.textarea.Value()
+			if len(value) > 0 && value[len(value)-1] == '\\' {
+				// If the last character is a backslash, remove it and add a newline
+				m.textarea.SetValue(value[:len(value)-1] + "\n")
+				return m, nil
+			} else {
+				// Otherwise, send the message
+				return m, m.send()
+			}
+		}
+	}
+	m.textarea, cmd = m.textarea.Update(msg)
+	cmds = append(cmds, cmd)
+	return m, tea.Batch(cmds...)
+}
+
+func (m *editorCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	cursor := m.textarea.Cursor()
+	if cursor != nil {
+		cursor.X = cursor.X + m.x + 1
+		cursor.Y = cursor.Y + m.y + 1 // adjust for padding
+	}
+	if len(m.attachments) == 0 {
+		content := t.S().Base.Padding(1).Render(
+			m.textarea.View(),
+		)
+		view := tea.NewView(content)
+		view.SetCursor(cursor)
+		return view
+	}
+	content := t.S().Base.Padding(0, 1, 1, 1).Render(
+		lipgloss.JoinVertical(lipgloss.Top,
+			m.attachmentsContent(),
+			m.textarea.View(),
+		),
+	)
+	view := tea.NewView(content)
+	view.SetCursor(cursor)
+	return view
+}
+
+func (m *editorCmp) SetSize(width, height int) tea.Cmd {
+	m.width = width
+	m.height = height
+	m.textarea.SetWidth(width - 2)   // adjust for padding
+	m.textarea.SetHeight(height - 2) // adjust for padding
+	return nil
+}
+
+func (m *editorCmp) GetSize() (int, int) {
+	return m.textarea.Width(), m.textarea.Height()
+}
+
+func (m *editorCmp) attachmentsContent() string {
+	var styledAttachments []string
+	t := styles.CurrentTheme()
+	attachmentStyles := t.S().Base.
+		MarginLeft(1).
+		Background(t.FgMuted).
+		Foreground(t.FgBase)
+	for i, attachment := range m.attachments {
+		var filename string
+		if len(attachment.FileName) > 10 {
+			filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
+		} else {
+			filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
+		}
+		if m.deleteMode {
+			filename = fmt.Sprintf("%d%s", i, filename)
+		}
+		styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
+	}
+	content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
+	return content
+}
+
+func (m *editorCmp) BindingKeys() []key.Binding {
+	bindings := []key.Binding{}
+	bindings = append(bindings, layout.KeyMapToSlice(m.keyMap)...)
+	bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
+	return bindings
+}
+
+func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
+	m.x = x
+	m.y = y
+	return nil
+}
+
+func (m *editorCmp) startCompletions() tea.Msg {
+	files, _, _ := fileutil.ListDirectory(".", []string{}, 0)
+	completionItems := make([]completions.Completion, 0, len(files))
+	for _, file := range files {
+		file = strings.TrimPrefix(file, "./")
+		completionItems = append(completionItems, completions.Completion{
+			Title: file,
+			Value: FileCompletionItem{
+				Path: file,
+			},
+		})
+	}
+
+	x := m.textarea.Cursor().X + m.x + 1
+	y := m.textarea.Cursor().Y + m.y + 1
+	return completions.OpenCompletionsMsg{
+		Completions: completionItems,
+		X:           x,
+		Y:           y,
+	}
+}
+
+// Blur implements Container.
+func (c *editorCmp) Blur() tea.Cmd {
+	c.textarea.Blur()
+	return nil
+}
+
+// Focus implements Container.
+func (c *editorCmp) Focus() tea.Cmd {
+	return c.textarea.Focus()
+}
+
+// IsFocused implements Container.
+func (c *editorCmp) IsFocused() bool {
+	return c.textarea.Focused()
+}
+
+func NewEditorCmp(app *app.App) util.Model {
+	t := styles.CurrentTheme()
+	ta := textarea.New()
+	ta.SetStyles(t.S().TextArea)
+	ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
+		if lineIndex == 0 {
+			return "  > "
+		}
+		if focused {
+			return t.S().Base.Foreground(t.GreenDark).Render("::: ")
+		} else {
+			return t.S().Muted.Render("::: ")
+		}
+	})
+	ta.ShowLineNumbers = false
+	ta.CharLimit = -1
+	ta.Placeholder = "Tell me more about this project..."
+	ta.SetVirtualCursor(false)
+	ta.Focus()
+
+	return &editorCmp{
+		app:      app,
+		textarea: ta,
+		keyMap:   DefaultEditorKeyMap(),
+	}
+}

internal/tui/components/chat/editor/keys.go 🔗

@@ -0,0 +1,59 @@
+package editor
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type EditorKeyMap struct {
+	Send       key.Binding
+	OpenEditor key.Binding
+}
+
+func DefaultEditorKeyMap() EditorKeyMap {
+	return EditorKeyMap{
+		Send: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "send"),
+		),
+		OpenEditor: key.NewBinding(
+			key.WithKeys("ctrl+e"),
+			key.WithHelp("ctrl+e", "open editor"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k EditorKeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k EditorKeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Send,
+		k.OpenEditor,
+	}
+}
+
+// TODO: update this to use the new keymap concepts
+var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
+	AttachmentDeleteMode: key.NewBinding(
+		key.WithKeys("ctrl+r"),
+		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
+	),
+	Escape: key.NewBinding(
+		key.WithKeys("esc"),
+		key.WithHelp("esc", "cancel delete mode"),
+	),
+	DeleteAllAttachments: key.NewBinding(
+		key.WithKeys("r"),
+		key.WithHelp("ctrl+r+r", "delete all attachments"),
+	),
+}

internal/tui/components/chat/list.go 🔗

@@ -1,487 +0,0 @@
-package chat
-
-import (
-	"context"
-	"fmt"
-	"math"
-
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/spinner"
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/pubsub"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type cacheItem struct {
-	width   int
-	content []uiMessage
-}
-type messagesCmp struct {
-	app           *app.App
-	width, height int
-	viewport      viewport.Model
-	session       session.Session
-	messages      []message.Message
-	uiMessages    []uiMessage
-	currentMsgID  string
-	cachedContent map[string]cacheItem
-	spinner       spinner.Model
-	rendering     bool
-	attachments   viewport.Model
-}
-type renderFinishedMsg struct{}
-
-type MessageKeys struct {
-	PageDown     key.Binding
-	PageUp       key.Binding
-	HalfPageUp   key.Binding
-	HalfPageDown key.Binding
-}
-
-var messageKeys = MessageKeys{
-	PageDown: key.NewBinding(
-		key.WithKeys("pgdown"),
-		key.WithHelp("f/pgdn", "page down"),
-	),
-	PageUp: key.NewBinding(
-		key.WithKeys("pgup"),
-		key.WithHelp("b/pgup", "page up"),
-	),
-	HalfPageUp: key.NewBinding(
-		key.WithKeys("ctrl+u"),
-		key.WithHelp("ctrl+u", "½ page up"),
-	),
-	HalfPageDown: key.NewBinding(
-		key.WithKeys("ctrl+d", "ctrl+d"),
-		key.WithHelp("ctrl+d", "½ page down"),
-	),
-}
-
-func (m *messagesCmp) Init() tea.Cmd {
-	return tea.Batch(m.viewport.Init(), m.spinner.Tick)
-}
-
-func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case dialog.ThemeChangedMsg:
-		m.rerender()
-		return m, nil
-	case SessionSelectedMsg:
-		if msg.ID != m.session.ID {
-			cmd := m.SetSession(msg)
-			return m, cmd
-		}
-		return m, nil
-	case SessionClearedMsg:
-		m.session = session.Session{}
-		m.messages = make([]message.Message, 0)
-		m.currentMsgID = ""
-		m.rendering = false
-		return m, nil
-
-	case tea.KeyMsg:
-		if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
-			key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
-			u, cmd := m.viewport.Update(msg)
-			m.viewport = u
-			cmds = append(cmds, cmd)
-		}
-
-	case renderFinishedMsg:
-		m.rendering = false
-		m.viewport.GotoBottom()
-	case pubsub.Event[session.Session]:
-		if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.session.ID {
-			m.session = msg.Payload
-			if m.session.SummaryMessageID == m.currentMsgID {
-				delete(m.cachedContent, m.currentMsgID)
-				m.renderView()
-			}
-		}
-	case pubsub.Event[message.Message]:
-		needsRerender := false
-		if msg.Type == pubsub.CreatedEvent {
-			if msg.Payload.SessionID == m.session.ID {
-
-				messageExists := false
-				for _, v := range m.messages {
-					if v.ID == msg.Payload.ID {
-						messageExists = true
-						break
-					}
-				}
-
-				if !messageExists {
-					if len(m.messages) > 0 {
-						lastMsgID := m.messages[len(m.messages)-1].ID
-						delete(m.cachedContent, lastMsgID)
-					}
-
-					m.messages = append(m.messages, msg.Payload)
-					delete(m.cachedContent, m.currentMsgID)
-					m.currentMsgID = msg.Payload.ID
-					needsRerender = true
-				}
-			}
-			// There are tool calls from the child task
-			for _, v := range m.messages {
-				for _, c := range v.ToolCalls() {
-					if c.ID == msg.Payload.SessionID {
-						delete(m.cachedContent, v.ID)
-						needsRerender = true
-					}
-				}
-			}
-		} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
-			for i, v := range m.messages {
-				if v.ID == msg.Payload.ID {
-					m.messages[i] = msg.Payload
-					delete(m.cachedContent, msg.Payload.ID)
-					needsRerender = true
-					break
-				}
-			}
-		}
-		if needsRerender {
-			m.renderView()
-			if len(m.messages) > 0 {
-				if (msg.Type == pubsub.CreatedEvent) ||
-					(msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
-					m.viewport.GotoBottom()
-				}
-			}
-		}
-	}
-
-	spinner, cmd := m.spinner.Update(msg)
-	m.spinner = spinner
-	cmds = append(cmds, cmd)
-	return m, tea.Batch(cmds...)
-}
-
-func (m *messagesCmp) IsAgentWorking() bool {
-	return m.app.CoderAgent.IsSessionBusy(m.session.ID)
-}
-
-func formatTimeDifference(unixTime1, unixTime2 int64) string {
-	diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
-
-	if diffSeconds < 60 {
-		return fmt.Sprintf("%.1fs", diffSeconds)
-	}
-
-	minutes := int(diffSeconds / 60)
-	seconds := int(diffSeconds) % 60
-	return fmt.Sprintf("%dm%ds", minutes, seconds)
-}
-
-func (m *messagesCmp) renderView() {
-	m.uiMessages = make([]uiMessage, 0)
-	pos := 0
-	baseStyle := styles.BaseStyle()
-
-	if m.width == 0 {
-		return
-	}
-	for inx, msg := range m.messages {
-		switch msg.Role {
-		case message.User:
-			if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
-				m.uiMessages = append(m.uiMessages, cache.content...)
-				continue
-			}
-			userMsg := renderUserMessage(
-				msg,
-				msg.ID == m.currentMsgID,
-				m.width,
-				pos,
-			)
-			m.uiMessages = append(m.uiMessages, userMsg)
-			m.cachedContent[msg.ID] = cacheItem{
-				width:   m.width,
-				content: []uiMessage{userMsg},
-			}
-			pos += userMsg.height + 1 // + 1 for spacing
-		case message.Assistant:
-			if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
-				m.uiMessages = append(m.uiMessages, cache.content...)
-				continue
-			}
-			isSummary := m.session.SummaryMessageID == msg.ID
-
-			assistantMessages := renderAssistantMessage(
-				msg,
-				inx,
-				m.messages,
-				m.app.Messages,
-				m.currentMsgID,
-				isSummary,
-				m.width,
-				pos,
-			)
-			for _, msg := range assistantMessages {
-				m.uiMessages = append(m.uiMessages, msg)
-				pos += msg.height + 1 // + 1 for spacing
-			}
-			m.cachedContent[msg.ID] = cacheItem{
-				width:   m.width,
-				content: assistantMessages,
-			}
-		}
-	}
-
-	messages := make([]string, 0)
-	for _, v := range m.uiMessages {
-		messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content),
-			baseStyle.
-				Width(m.width).
-				Render(
-					"",
-				),
-		)
-	}
-
-	m.viewport.SetContent(
-		baseStyle.
-			Width(m.width).
-			Render(
-				lipgloss.JoinVertical(
-					lipgloss.Top,
-					messages...,
-				),
-			),
-	)
-}
-
-func (m *messagesCmp) View() string {
-	baseStyle := styles.BaseStyle()
-
-	if m.rendering {
-		return baseStyle.
-			Width(m.width).
-			Render(
-				lipgloss.JoinVertical(
-					lipgloss.Top,
-					"Loading...",
-					m.working(),
-					m.help(),
-				),
-			)
-	}
-	if len(m.messages) == 0 {
-		content := baseStyle.
-			Width(m.width).
-			Height(m.height - 1).
-			Render(
-				m.initialScreen(),
-			)
-
-		return baseStyle.
-			Width(m.width).
-			Render(
-				lipgloss.JoinVertical(
-					lipgloss.Top,
-					content,
-					"",
-					m.help(),
-				),
-			)
-	}
-
-	return baseStyle.
-		Width(m.width).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Top,
-				m.viewport.View(),
-				m.working(),
-				m.help(),
-			),
-		)
-}
-
-func hasToolsWithoutResponse(messages []message.Message) bool {
-	toolCalls := make([]message.ToolCall, 0)
-	toolResults := make([]message.ToolResult, 0)
-	for _, m := range messages {
-		toolCalls = append(toolCalls, m.ToolCalls()...)
-		toolResults = append(toolResults, m.ToolResults()...)
-	}
-
-	for _, v := range toolCalls {
-		found := false
-		for _, r := range toolResults {
-			if v.ID == r.ToolCallID {
-				found = true
-				break
-			}
-		}
-		if !found && v.Finished {
-			return true
-		}
-	}
-	return false
-}
-
-func hasUnfinishedToolCalls(messages []message.Message) bool {
-	toolCalls := make([]message.ToolCall, 0)
-	for _, m := range messages {
-		toolCalls = append(toolCalls, m.ToolCalls()...)
-	}
-	for _, v := range toolCalls {
-		if !v.Finished {
-			return true
-		}
-	}
-	return false
-}
-
-func (m *messagesCmp) working() string {
-	text := ""
-	if m.IsAgentWorking() && len(m.messages) > 0 {
-		t := theme.CurrentTheme()
-		baseStyle := styles.BaseStyle()
-
-		task := "Thinking..."
-		lastMessage := m.messages[len(m.messages)-1]
-		if hasToolsWithoutResponse(m.messages) {
-			task = "Waiting for tool response..."
-		} else if hasUnfinishedToolCalls(m.messages) {
-			task = "Building tool call..."
-		} else if !lastMessage.IsFinished() {
-			task = "Generating..."
-		}
-		if task != "" {
-			text += baseStyle.
-				Width(m.width).
-				Foreground(t.Primary()).
-				Bold(true).
-				Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
-		}
-	}
-	return text
-}
-
-func (m *messagesCmp) help() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	text := ""
-
-	if m.app.CoderAgent.IsBusy() {
-		text += lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
-			baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
-			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"),
-		)
-	} else {
-		text += lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
-			baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
-			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"),
-			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"),
-			baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
-			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"),
-		)
-	}
-	return baseStyle.
-		Width(m.width).
-		Render(text)
-}
-
-func (m *messagesCmp) initialScreen() string {
-	baseStyle := styles.BaseStyle()
-
-	return baseStyle.Width(m.width).Render(
-		lipgloss.JoinVertical(
-			lipgloss.Top,
-			header(m.width),
-			"",
-			lspsConfigured(m.width),
-		),
-	)
-}
-
-func (m *messagesCmp) rerender() {
-	for _, msg := range m.messages {
-		delete(m.cachedContent, msg.ID)
-	}
-	m.renderView()
-}
-
-func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
-	if m.width == width && m.height == height {
-		return nil
-	}
-	m.width = width
-	m.height = height
-	m.viewport.Width = width
-	m.viewport.Height = height - 2
-	m.attachments.Width = width + 40
-	m.attachments.Height = 3
-	m.rerender()
-	return nil
-}
-
-func (m *messagesCmp) GetSize() (int, int) {
-	return m.width, m.height
-}
-
-func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
-	if m.session.ID == session.ID {
-		return nil
-	}
-	m.session = session
-	messages, err := m.app.Messages.List(context.Background(), session.ID)
-	if err != nil {
-		return util.ReportError(err)
-	}
-	m.messages = messages
-	if len(m.messages) > 0 {
-		m.currentMsgID = m.messages[len(m.messages)-1].ID
-	}
-	delete(m.cachedContent, m.currentMsgID)
-	m.rendering = true
-	return func() tea.Msg {
-		m.renderView()
-		return renderFinishedMsg{}
-	}
-}
-
-func (m *messagesCmp) BindingKeys() []key.Binding {
-	return []key.Binding{
-		m.viewport.KeyMap.PageDown,
-		m.viewport.KeyMap.PageUp,
-		m.viewport.KeyMap.HalfPageUp,
-		m.viewport.KeyMap.HalfPageDown,
-	}
-}
-
-func NewMessagesCmp(app *app.App) tea.Model {
-	s := spinner.New()
-	s.Spinner = spinner.Pulse
-	vp := viewport.New(0, 0)
-	attachmets := viewport.New(0, 0)
-	vp.KeyMap.PageUp = messageKeys.PageUp
-	vp.KeyMap.PageDown = messageKeys.PageDown
-	vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
-	vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
-	return &messagesCmp{
-		app:           app,
-		cachedContent: make(map[string]cacheItem),
-		viewport:      vp,
-		spinner:       s,
-		attachments:   attachmets,
-	}
-}

internal/tui/components/chat/message.go 🔗

@@ -1,659 +0,0 @@
-package chat
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/diff"
-	"github.com/opencode-ai/opencode/internal/llm/agent"
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-)
-
-type uiMessageType int
-
-const (
-	userMessageType uiMessageType = iota
-	assistantMessageType
-	toolMessageType
-
-	maxResultHeight = 10
-)
-
-type uiMessage struct {
-	ID          string
-	messageType uiMessageType
-	position    int
-	height      int
-	content     string
-}
-
-func toMarkdown(content string, focused bool, width int) string {
-	r := styles.GetMarkdownRenderer(width)
-	rendered, _ := r.Render(content)
-	return rendered
-}
-
-func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
-	t := theme.CurrentTheme()
-
-	style := styles.BaseStyle().
-		Width(width - 1).
-		BorderLeft(true).
-		Foreground(t.TextMuted()).
-		BorderForeground(t.Primary()).
-		BorderStyle(lipgloss.ThickBorder())
-
-	if isUser {
-		style = style.BorderForeground(t.Secondary())
-	}
-
-	// Apply markdown formatting and handle background color
-	parts := []string{
-		styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()),
-	}
-
-	// Remove newline at the end
-	parts[0] = strings.TrimSuffix(parts[0], "\n")
-	if len(info) > 0 {
-		parts = append(parts, info...)
-	}
-
-	rendered := style.Render(
-		lipgloss.JoinVertical(
-			lipgloss.Left,
-			parts...,
-		),
-	)
-
-	return rendered
-}
-
-func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
-	var styledAttachments []string
-	t := theme.CurrentTheme()
-	attachmentStyles := styles.BaseStyle().
-		MarginLeft(1).
-		Background(t.TextMuted()).
-		Foreground(t.Text())
-	for _, attachment := range msg.BinaryContent() {
-		file := filepath.Base(attachment.Path)
-		var filename string
-		if len(file) > 10 {
-			filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
-		} else {
-			filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
-		}
-		styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
-	}
-	content := ""
-	if len(styledAttachments) > 0 {
-		attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
-		content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent)
-	} else {
-		content = renderMessage(msg.Content().String(), true, isFocused, width)
-	}
-	userMsg := uiMessage{
-		ID:          msg.ID,
-		messageType: userMessageType,
-		position:    position,
-		height:      lipgloss.Height(content),
-		content:     content,
-	}
-	return userMsg
-}
-
-// Returns multiple uiMessages because of the tool calls
-func renderAssistantMessage(
-	msg message.Message,
-	msgIndex int,
-	allMessages []message.Message, // we need this to get tool results and the user message
-	messagesService message.Service, // We need this to get the task tool messages
-	focusedUIMessageId string,
-	isSummary bool,
-	width int,
-	position int,
-) []uiMessage {
-	messages := []uiMessage{}
-	content := msg.Content().String()
-	thinking := msg.IsThinking()
-	thinkingContent := msg.ReasoningContent().Thinking
-	finished := msg.IsFinished()
-	finishData := msg.FinishPart()
-	info := []string{}
-
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	// Add finish info if available
-	if finished {
-		switch finishData.Reason {
-		case message.FinishReasonEndTurn:
-			took := formatTimestampDiff(msg.CreatedAt, finishData.Time)
-			info = append(info, baseStyle.
-				Width(width-1).
-				Foreground(t.TextMuted()).
-				Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took)),
-			)
-		case message.FinishReasonCanceled:
-			info = append(info, baseStyle.
-				Width(width-1).
-				Foreground(t.TextMuted()).
-				Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled")),
-			)
-		case message.FinishReasonError:
-			info = append(info, baseStyle.
-				Width(width-1).
-				Foreground(t.TextMuted()).
-				Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error")),
-			)
-		case message.FinishReasonPermissionDenied:
-			info = append(info, baseStyle.
-				Width(width-1).
-				Foreground(t.TextMuted()).
-				Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied")),
-			)
-		}
-	}
-	if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
-		if content == "" {
-			content = "*Finished without output*"
-		}
-		if isSummary {
-			info = append(info, baseStyle.Width(width-1).Foreground(t.TextMuted()).Render(" (summary)"))
-		}
-
-		content = renderMessage(content, false, true, width, info...)
-		messages = append(messages, uiMessage{
-			ID:          msg.ID,
-			messageType: assistantMessageType,
-			position:    position,
-			height:      lipgloss.Height(content),
-			content:     content,
-		})
-		position += messages[0].height
-		position++ // for the space
-	} else if thinking && thinkingContent != "" {
-		// Render the thinking content
-		content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width)
-	}
-
-	for i, toolCall := range msg.ToolCalls() {
-		toolCallContent := renderToolMessage(
-			toolCall,
-			allMessages,
-			messagesService,
-			focusedUIMessageId,
-			false,
-			width,
-			i+1,
-		)
-		messages = append(messages, toolCallContent)
-		position += toolCallContent.height
-		position++ // for the space
-	}
-	return messages
-}
-
-func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
-	for _, msg := range futureMessages {
-		for _, result := range msg.ToolResults() {
-			if result.ToolCallID == toolCallID {
-				return &result
-			}
-		}
-	}
-	return nil
-}
-
-func toolName(name string) string {
-	switch name {
-	case agent.AgentToolName:
-		return "Task"
-	case tools.BashToolName:
-		return "Bash"
-	case tools.EditToolName:
-		return "Edit"
-	case tools.FetchToolName:
-		return "Fetch"
-	case tools.GlobToolName:
-		return "Glob"
-	case tools.GrepToolName:
-		return "Grep"
-	case tools.LSToolName:
-		return "List"
-	case tools.SourcegraphToolName:
-		return "Sourcegraph"
-	case tools.ViewToolName:
-		return "View"
-	case tools.WriteToolName:
-		return "Write"
-	case tools.PatchToolName:
-		return "Patch"
-	}
-	return name
-}
-
-func getToolAction(name string) string {
-	switch name {
-	case agent.AgentToolName:
-		return "Preparing prompt..."
-	case tools.BashToolName:
-		return "Building command..."
-	case tools.EditToolName:
-		return "Preparing edit..."
-	case tools.FetchToolName:
-		return "Writing fetch..."
-	case tools.GlobToolName:
-		return "Finding files..."
-	case tools.GrepToolName:
-		return "Searching content..."
-	case tools.LSToolName:
-		return "Listing directory..."
-	case tools.SourcegraphToolName:
-		return "Searching code..."
-	case tools.ViewToolName:
-		return "Reading file..."
-	case tools.WriteToolName:
-		return "Preparing write..."
-	case tools.PatchToolName:
-		return "Preparing patch..."
-	}
-	return "Working..."
-}
-
-// renders params, params[0] (params[1]=params[2] ....)
-func renderParams(paramsWidth int, params ...string) string {
-	if len(params) == 0 {
-		return ""
-	}
-	mainParam := params[0]
-	if len(mainParam) > paramsWidth {
-		mainParam = mainParam[:paramsWidth-3] + "..."
-	}
-
-	if len(params) == 1 {
-		return mainParam
-	}
-	otherParams := params[1:]
-	// create pairs of key/value
-	// if odd number of params, the last one is a key without value
-	if len(otherParams)%2 != 0 {
-		otherParams = append(otherParams, "")
-	}
-	parts := make([]string, 0, len(otherParams)/2)
-	for i := 0; i < len(otherParams); i += 2 {
-		key := otherParams[i]
-		value := otherParams[i+1]
-		if value == "" {
-			continue
-		}
-		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
-	}
-
-	partsRendered := strings.Join(parts, ", ")
-	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
-	if remainingWidth < 30 {
-		// No space for the params, just show the main
-		return mainParam
-	}
-
-	if len(parts) > 0 {
-		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
-	}
-
-	return ansi.Truncate(mainParam, paramsWidth, "...")
-}
-
-func removeWorkingDirPrefix(path string) string {
-	wd := config.WorkingDirectory()
-	if strings.HasPrefix(path, wd) {
-		path = strings.TrimPrefix(path, wd)
-	}
-	if strings.HasPrefix(path, "/") {
-		path = strings.TrimPrefix(path, "/")
-	}
-	if strings.HasPrefix(path, "./") {
-		path = strings.TrimPrefix(path, "./")
-	}
-	if strings.HasPrefix(path, "../") {
-		path = strings.TrimPrefix(path, "../")
-	}
-	return path
-}
-
-func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
-	params := ""
-	switch toolCall.Name {
-	case agent.AgentToolName:
-		var params agent.AgentParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
-		return renderParams(paramWidth, prompt)
-	case tools.BashToolName:
-		var params tools.BashParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		command := strings.ReplaceAll(params.Command, "\n", " ")
-		return renderParams(paramWidth, command)
-	case tools.EditToolName:
-		var params tools.EditParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		filePath := removeWorkingDirPrefix(params.FilePath)
-		return renderParams(paramWidth, filePath)
-	case tools.FetchToolName:
-		var params tools.FetchParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		url := params.URL
-		toolParams := []string{
-			url,
-		}
-		if params.Format != "" {
-			toolParams = append(toolParams, "format", params.Format)
-		}
-		if params.Timeout != 0 {
-			toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
-		}
-		return renderParams(paramWidth, toolParams...)
-	case tools.GlobToolName:
-		var params tools.GlobParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		pattern := params.Pattern
-		toolParams := []string{
-			pattern,
-		}
-		if params.Path != "" {
-			toolParams = append(toolParams, "path", params.Path)
-		}
-		return renderParams(paramWidth, toolParams...)
-	case tools.GrepToolName:
-		var params tools.GrepParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		pattern := params.Pattern
-		toolParams := []string{
-			pattern,
-		}
-		if params.Path != "" {
-			toolParams = append(toolParams, "path", params.Path)
-		}
-		if params.Include != "" {
-			toolParams = append(toolParams, "include", params.Include)
-		}
-		if params.LiteralText {
-			toolParams = append(toolParams, "literal", "true")
-		}
-		return renderParams(paramWidth, toolParams...)
-	case tools.LSToolName:
-		var params tools.LSParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		path := params.Path
-		if path == "" {
-			path = "."
-		}
-		return renderParams(paramWidth, path)
-	case tools.SourcegraphToolName:
-		var params tools.SourcegraphParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		return renderParams(paramWidth, params.Query)
-	case tools.ViewToolName:
-		var params tools.ViewParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		filePath := removeWorkingDirPrefix(params.FilePath)
-		toolParams := []string{
-			filePath,
-		}
-		if params.Limit != 0 {
-			toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
-		}
-		if params.Offset != 0 {
-			toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
-		}
-		return renderParams(paramWidth, toolParams...)
-	case tools.WriteToolName:
-		var params tools.WriteParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		filePath := removeWorkingDirPrefix(params.FilePath)
-		return renderParams(paramWidth, filePath)
-	default:
-		input := strings.ReplaceAll(toolCall.Input, "\n", " ")
-		params = renderParams(paramWidth, input)
-	}
-	return params
-}
-
-func truncateHeight(content string, height int) string {
-	lines := strings.Split(content, "\n")
-	if len(lines) > height {
-		return strings.Join(lines[:height], "\n")
-	}
-	return content
-}
-
-func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	if response.IsError {
-		errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
-		errContent = ansi.Truncate(errContent, width-1, "...")
-		return baseStyle.
-			Width(width).
-			Foreground(t.Error()).
-			Render(errContent)
-	}
-
-	resultContent := truncateHeight(response.Content, maxResultHeight)
-	switch toolCall.Name {
-	case agent.AgentToolName:
-		return styles.ForceReplaceBackgroundWithLipgloss(
-			toMarkdown(resultContent, false, width),
-			t.Background(),
-		)
-	case tools.BashToolName:
-		resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
-		return styles.ForceReplaceBackgroundWithLipgloss(
-			toMarkdown(resultContent, true, width),
-			t.Background(),
-		)
-	case tools.EditToolName:
-		metadata := tools.EditResponseMetadata{}
-		json.Unmarshal([]byte(response.Metadata), &metadata)
-		truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
-		formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width))
-		return formattedDiff
-	case tools.FetchToolName:
-		var params tools.FetchParams
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		mdFormat := "markdown"
-		switch params.Format {
-		case "text":
-			mdFormat = "text"
-		case "html":
-			mdFormat = "html"
-		}
-		resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
-		return styles.ForceReplaceBackgroundWithLipgloss(
-			toMarkdown(resultContent, true, width),
-			t.Background(),
-		)
-	case tools.GlobToolName:
-		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
-	case tools.GrepToolName:
-		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
-	case tools.LSToolName:
-		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
-	case tools.SourcegraphToolName:
-		return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
-	case tools.ViewToolName:
-		metadata := tools.ViewResponseMetadata{}
-		json.Unmarshal([]byte(response.Metadata), &metadata)
-		ext := filepath.Ext(metadata.FilePath)
-		if ext == "" {
-			ext = ""
-		} else {
-			ext = strings.ToLower(ext[1:])
-		}
-		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
-		return styles.ForceReplaceBackgroundWithLipgloss(
-			toMarkdown(resultContent, true, width),
-			t.Background(),
-		)
-	case tools.WriteToolName:
-		params := tools.WriteParams{}
-		json.Unmarshal([]byte(toolCall.Input), &params)
-		metadata := tools.WriteResponseMetadata{}
-		json.Unmarshal([]byte(response.Metadata), &metadata)
-		ext := filepath.Ext(params.FilePath)
-		if ext == "" {
-			ext = ""
-		} else {
-			ext = strings.ToLower(ext[1:])
-		}
-		resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
-		return styles.ForceReplaceBackgroundWithLipgloss(
-			toMarkdown(resultContent, true, width),
-			t.Background(),
-		)
-	default:
-		resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
-		return styles.ForceReplaceBackgroundWithLipgloss(
-			toMarkdown(resultContent, true, width),
-			t.Background(),
-		)
-	}
-}
-
-func renderToolMessage(
-	toolCall message.ToolCall,
-	allMessages []message.Message,
-	messagesService message.Service,
-	focusedUIMessageId string,
-	nested bool,
-	width int,
-	position int,
-) uiMessage {
-	if nested {
-		width = width - 3
-	}
-
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	style := baseStyle.
-		Width(width - 1).
-		BorderLeft(true).
-		BorderStyle(lipgloss.ThickBorder()).
-		PaddingLeft(1).
-		BorderForeground(t.TextMuted())
-
-	response := findToolResponse(toolCall.ID, allMessages)
-	toolNameText := baseStyle.Foreground(t.TextMuted()).
-		Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
-
-	if !toolCall.Finished {
-		// Get a brief description of what the tool is doing
-		toolAction := getToolAction(toolCall.Name)
-
-		progressText := baseStyle.
-			Width(width - 2 - lipgloss.Width(toolNameText)).
-			Foreground(t.TextMuted()).
-			Render(fmt.Sprintf("%s", toolAction))
-
-		content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
-		toolMsg := uiMessage{
-			messageType: toolMessageType,
-			position:    position,
-			height:      lipgloss.Height(content),
-			content:     content,
-		}
-		return toolMsg
-	}
-
-	params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall)
-	responseContent := ""
-	if response != nil {
-		responseContent = renderToolResponse(toolCall, *response, width-2)
-		responseContent = strings.TrimSuffix(responseContent, "\n")
-	} else {
-		responseContent = baseStyle.
-			Italic(true).
-			Width(width - 2).
-			Foreground(t.TextMuted()).
-			Render("Waiting for response...")
-	}
-
-	parts := []string{}
-	if !nested {
-		formattedParams := baseStyle.
-			Width(width - 2 - lipgloss.Width(toolNameText)).
-			Foreground(t.TextMuted()).
-			Render(params)
-
-		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
-	} else {
-		prefix := baseStyle.
-			Foreground(t.TextMuted()).
-			Render(" └ ")
-		formattedParams := baseStyle.
-			Width(width - 2 - lipgloss.Width(toolNameText)).
-			Foreground(t.TextMuted()).
-			Render(params)
-		parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
-	}
-
-	if toolCall.Name == agent.AgentToolName {
-		taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
-		toolCalls := []message.ToolCall{}
-		for _, v := range taskMessages {
-			toolCalls = append(toolCalls, v.ToolCalls()...)
-		}
-		for _, call := range toolCalls {
-			rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
-			parts = append(parts, rendered.content)
-		}
-	}
-	if responseContent != "" && !nested {
-		parts = append(parts, responseContent)
-	}
-
-	content := style.Render(
-		lipgloss.JoinVertical(
-			lipgloss.Left,
-			parts...,
-		),
-	)
-	if nested {
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			parts...,
-		)
-	}
-	toolMsg := uiMessage{
-		messageType: toolMessageType,
-		position:    position,
-		height:      lipgloss.Height(content),
-		content:     content,
-	}
-	return toolMsg
-}
-
-// Helper function to format the time difference between two Unix timestamps
-func formatTimestampDiff(start, end int64) string {
-	diffSeconds := float64(end-start) / 1000.0 // Convert to seconds
-	if diffSeconds < 1 {
-		return fmt.Sprintf("%dms", int(diffSeconds*1000))
-	}
-	if diffSeconds < 60 {
-		return fmt.Sprintf("%.1fs", diffSeconds)
-	}
-	return fmt.Sprintf("%.1fm", diffSeconds/60)
-}

internal/tui/components/chat/messages/messages.go 🔗

@@ -0,0 +1,289 @@
+package messages
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/v2/spinner"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/tui/components/anim"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+// MessageCmp defines the interface for message components in the chat interface.
+// It combines standard UI model interfaces with message-specific functionality.
+type MessageCmp interface {
+	util.Model                   // Basic Bubble Tea model interface
+	layout.Sizeable              // Width/height management
+	layout.Focusable             // Focus state management
+	GetMessage() message.Message // Access to underlying message data
+	Spinning() bool              // Animation state for loading messages
+}
+
+// messageCmp implements the MessageCmp interface for displaying chat messages.
+// It handles rendering of user and assistant messages with proper styling,
+// animations, and state management.
+type messageCmp struct {
+	width   int  // Component width for text wrapping
+	focused bool // Focus state for border styling
+
+	// Core message data and state
+	message             message.Message // The underlying message content
+	spinning            bool            // Whether to show loading animation
+	anim                util.Model      // Animation component for loading states
+	lastUserMessageTime time.Time       // Used for calculating response duration
+}
+
+// MessageOption provides functional options for configuring message components
+type MessageOption func(*messageCmp)
+
+// WithLastUserMessageTime sets the timestamp of the last user message
+// for calculating assistant response duration
+func WithLastUserMessageTime(t time.Time) MessageOption {
+	return func(m *messageCmp) {
+		m.lastUserMessageTime = t
+	}
+}
+
+// NewMessageCmp creates a new message component with the given message and options
+func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
+	m := &messageCmp{
+		message: msg,
+		anim:    anim.New(15, ""),
+	}
+	for _, opt := range opts {
+		opt(m)
+	}
+	return m
+}
+
+// Init initializes the message component and starts animations if needed.
+// Returns a command to start the animation for spinning messages.
+func (m *messageCmp) Init() tea.Cmd {
+	m.spinning = m.shouldSpin()
+	if m.spinning {
+		return m.anim.Init()
+	}
+	return nil
+}
+
+// Update handles incoming messages and updates the component state.
+// Manages animation updates for spinning messages and stops animation when appropriate.
+func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
+		m.spinning = m.shouldSpin()
+		if m.spinning {
+			u, cmd := m.anim.Update(msg)
+			m.anim = u.(util.Model)
+			return m, cmd
+		}
+	}
+	return m, nil
+}
+
+// View renders the message component based on its current state.
+// Returns different views for spinning, user, and assistant messages.
+func (m *messageCmp) View() tea.View {
+	if m.spinning {
+		return tea.NewView(m.style().PaddingLeft(1).Render(m.anim.View().String()))
+	}
+	if m.message.ID != "" {
+		// this is a user or assistant message
+		switch m.message.Role {
+		case message.User:
+			return tea.NewView(m.renderUserMessage())
+		default:
+			return tea.NewView(m.renderAssistantMessage())
+		}
+	}
+	return tea.NewView(m.style().Render("No message content"))
+}
+
+// GetMessage returns the underlying message data
+func (m *messageCmp) GetMessage() message.Message {
+	return m.message
+}
+
+// textWidth calculates the available width for text content,
+// accounting for borders and padding
+func (m *messageCmp) textWidth() int {
+	return m.width - 2 // take into account the border and/or padding
+}
+
+// style returns the lipgloss style for the message component.
+// Applies different border colors and styles based on message role and focus state.
+func (msg *messageCmp) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+	borderStyle := lipgloss.NormalBorder()
+	if msg.focused {
+		borderStyle = lipgloss.ThickBorder()
+	}
+
+	style := t.S().Text
+	if msg.message.Role == message.User {
+		style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
+	} else {
+		if msg.focused {
+			style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
+		} else {
+			style = style.PaddingLeft(2)
+		}
+	}
+	return style
+}
+
+// renderAssistantMessage renders assistant messages with optional footer information.
+// Shows model name, response time, and finish reason when the message is complete.
+func (m *messageCmp) renderAssistantMessage() string {
+	t := styles.CurrentTheme()
+	parts := []string{
+		m.markdownContent(),
+	}
+
+	finished := m.message.IsFinished()
+	finishData := m.message.FinishPart()
+	// Only show the footer if the message is not a tool call
+	if finished && finishData.Reason != message.FinishReasonToolUse {
+		infoMsg := ""
+		switch finishData.Reason {
+		case message.FinishReasonEndTurn:
+			finishTime := time.Unix(finishData.Time, 0)
+			duration := finishTime.Sub(m.lastUserMessageTime)
+			infoMsg = duration.String()
+		case message.FinishReasonCanceled:
+			infoMsg = "canceled"
+		case message.FinishReasonError:
+			infoMsg = "error"
+		case message.FinishReasonPermissionDenied:
+			infoMsg = "permission denied"
+		}
+		assistant := t.S().Muted.Render(fmt.Sprintf("⬡ %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
+		parts = append(parts, core.Section(assistant, m.textWidth()))
+	}
+
+	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
+	return m.style().Render(joined)
+}
+
+// renderUserMessage renders user messages with file attachments.
+// Displays message content and any attached files with appropriate icons.
+func (m *messageCmp) renderUserMessage() string {
+	t := styles.CurrentTheme()
+	parts := []string{
+		m.markdownContent(),
+	}
+	attachmentStyles := t.S().Text.
+		MarginLeft(1).
+		Background(t.BgSubtle)
+	attachments := []string{}
+	for _, attachment := range m.message.BinaryContent() {
+		file := filepath.Base(attachment.Path)
+		var filename string
+		if len(file) > 10 {
+			filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
+		} else {
+			filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
+		}
+		attachments = append(attachments, attachmentStyles.Render(filename))
+	}
+	if len(attachments) > 0 {
+		parts = append(parts, "", strings.Join(attachments, ""))
+	}
+	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
+	return m.style().MarginBottom(1).Render(joined)
+}
+
+// toMarkdown converts text content to rendered markdown using the configured renderer
+func (m *messageCmp) toMarkdown(content string) string {
+	r := styles.GetMarkdownRenderer(m.textWidth())
+	rendered, _ := r.Render(content)
+	return strings.TrimSuffix(rendered, "\n")
+}
+
+// markdownContent processes the message content and handles special states.
+// Returns appropriate content for thinking, finished, and error states.
+func (m *messageCmp) markdownContent() string {
+	content := m.message.Content().String()
+	if m.message.Role == message.Assistant {
+		thinking := m.message.IsThinking()
+		finished := m.message.IsFinished()
+		finishedData := m.message.FinishPart()
+		if thinking {
+			// Handle the thinking state
+			// TODO: maybe add the thinking content if available later.
+			content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
+		} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
+			// Sometimes the LLMs respond with no content when they think the previous tool result
+			//  provides the requested question
+			content = "*Finished without output*"
+		} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
+			content = "*Canceled*"
+		}
+	}
+	return m.toMarkdown(content)
+}
+
+// shouldSpin determines whether the message should show a loading animation.
+// Only assistant messages without content that aren't finished should spin.
+func (m *messageCmp) shouldSpin() bool {
+	if m.message.Role != message.Assistant {
+		return false
+	}
+
+	if m.message.IsFinished() {
+		return false
+	}
+
+	if m.message.Content().Text != "" {
+		return false
+	}
+	return true
+}
+
+// Focus management methods
+
+// Blur removes focus from the message component
+func (m *messageCmp) Blur() tea.Cmd {
+	m.focused = false
+	return nil
+}
+
+// Focus sets focus on the message component
+func (m *messageCmp) Focus() tea.Cmd {
+	m.focused = true
+	return nil
+}
+
+// IsFocused returns whether the message component is currently focused
+func (m *messageCmp) IsFocused() bool {
+	return m.focused
+}
+
+// Size management methods
+
+// GetSize returns the current dimensions of the message component
+func (m *messageCmp) GetSize() (int, int) {
+	return m.width, 0
+}
+
+// SetSize updates the width of the message component for text wrapping
+func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
+	// For better readability, we limit the width to a maximum of 120 characters
+	m.width = min(width, 120)
+	return nil
+}
+
+// Spinning returns whether the message is currently showing a loading animation
+func (m *messageCmp) Spinning() bool {
+	return m.spinning
+}

internal/tui/components/chat/messages/renderer.go 🔗

@@ -0,0 +1,719 @@
+package messages
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fileutil"
+	"github.com/charmbracelet/crush/internal/highlight"
+	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/lipgloss/v2/tree"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// responseContextHeight limits the number of lines displayed in tool output
+const responseContextHeight = 10
+
+// renderer defines the interface for tool-specific rendering implementations
+type renderer interface {
+	// Render returns the complete (already styled) tool‑call view, not
+	// including the outer border.
+	Render(v *toolCallCmp) string
+}
+
+// rendererFactory creates new renderer instances
+type rendererFactory func() renderer
+
+// renderRegistry manages the mapping of tool names to their renderers
+type renderRegistry map[string]rendererFactory
+
+// register adds a new renderer factory to the registry
+func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
+
+// lookup retrieves a renderer for the given tool name, falling back to generic renderer
+func (rr renderRegistry) lookup(name string) renderer {
+	if f, ok := rr[name]; ok {
+		return f()
+	}
+	return genericRenderer{} // sensible fallback
+}
+
+// registry holds all registered tool renderers
+var registry = renderRegistry{}
+
+// baseRenderer provides common functionality for all tool renderers
+type baseRenderer struct{}
+
+// paramBuilder helps construct parameter lists for tool headers
+type paramBuilder struct {
+	args []string
+}
+
+// newParamBuilder creates a new parameter builder
+func newParamBuilder() *paramBuilder {
+	return &paramBuilder{args: make([]string, 0)}
+}
+
+// addMain adds the main parameter (first argument)
+func (pb *paramBuilder) addMain(value string) *paramBuilder {
+	if value != "" {
+		pb.args = append(pb.args, value)
+	}
+	return pb
+}
+
+// addKeyValue adds a key-value pair parameter
+func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
+	if value != "" {
+		pb.args = append(pb.args, key, value)
+	}
+	return pb
+}
+
+// addFlag adds a boolean flag parameter
+func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
+	if value {
+		pb.args = append(pb.args, key, "true")
+	}
+	return pb
+}
+
+// build returns the final parameter list
+func (pb *paramBuilder) build() []string {
+	return pb.args
+}
+
+// renderWithParams provides a common rendering pattern for tools with parameters
+func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
+	width := v.textWidth()
+	if v.isNested {
+		width -= 4 // Adjust for nested tool call indentation
+	}
+	header := br.makeHeader(v, toolName, width, args...)
+	if v.isNested {
+		return v.style().Render(header)
+	}
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+	body := contentRenderer()
+	return joinHeaderBody(header, body)
+}
+
+// unmarshalParams safely unmarshal JSON parameters
+func (br baseRenderer) unmarshalParams(input string, target any) error {
+	return json.Unmarshal([]byte(input), target)
+}
+
+// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
+func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
+	t := styles.CurrentTheme()
+	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
+	if v.result.ToolCallID != "" {
+		if v.result.IsError {
+			icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
+		} else {
+			icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
+		}
+	} else if v.cancelled {
+		icon = t.S().Muted.Render(styles.ToolPending)
+	}
+	tool = t.S().Base.Foreground(t.Blue).Render(tool)
+	prefix := fmt.Sprintf("%s %s: ", icon, tool)
+	return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
+}
+
+// renderError provides consistent error rendering
+func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
+	t := styles.CurrentTheme()
+	header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
+	message = t.S().Error.Render(v.fit(message, v.textWidth()-2)) // -2 for padding
+	return joinHeaderBody(header, message)
+}
+
+// Register tool renderers
+func init() {
+	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
+	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
+	registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
+	registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
+	registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
+	registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
+	registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
+	registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
+	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
+	registry.register(tools.PatchToolName, func() renderer { return patchRenderer{} })
+	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
+	registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
+}
+
+// -----------------------------------------------------------------------------
+//  Generic renderer
+// -----------------------------------------------------------------------------
+
+// genericRenderer handles unknown tool types with basic parameter display
+type genericRenderer struct {
+	baseRenderer
+}
+
+// Render displays the tool call with its raw input and plain content output
+func (gr genericRenderer) Render(v *toolCallCmp) string {
+	return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  Bash renderer
+// -----------------------------------------------------------------------------
+
+// bashRenderer handles bash command execution display
+type bashRenderer struct {
+	baseRenderer
+}
+
+// Render displays the bash command with sanitized newlines and plain output
+func (br bashRenderer) Render(v *toolCallCmp) string {
+	var params tools.BashParams
+	if err := br.unmarshalParams(v.call.Input, &params); err != nil {
+		return br.renderError(v, "Invalid bash parameters")
+	}
+
+	cmd := strings.ReplaceAll(params.Command, "\n", " ")
+	args := newParamBuilder().addMain(cmd).build()
+
+	return br.renderWithParams(v, "Bash", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  View renderer
+// -----------------------------------------------------------------------------
+
+// viewRenderer handles file viewing with syntax highlighting and line numbers
+type viewRenderer struct {
+	baseRenderer
+}
+
+// Render displays file content with optional limit and offset parameters
+func (vr viewRenderer) Render(v *toolCallCmp) string {
+	var params tools.ViewParams
+	if err := vr.unmarshalParams(v.call.Input, &params); err != nil {
+		return vr.renderError(v, "Invalid view parameters")
+	}
+
+	file := fileutil.PrettyPath(params.FilePath)
+	args := newParamBuilder().
+		addMain(file).
+		addKeyValue("limit", formatNonZero(params.Limit)).
+		addKeyValue("offset", formatNonZero(params.Offset)).
+		build()
+
+	return vr.renderWithParams(v, "View", args, func() string {
+		var meta tools.ViewResponseMetadata
+		if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
+			return renderPlainContent(v, v.result.Content)
+		}
+		return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
+	})
+}
+
+// formatNonZero returns string representation of non-zero integers, empty string for zero
+func formatNonZero(value int) string {
+	if value == 0 {
+		return ""
+	}
+	return fmt.Sprintf("%d", value)
+}
+
+// -----------------------------------------------------------------------------
+//  Edit renderer
+// -----------------------------------------------------------------------------
+
+// editRenderer handles file editing with diff visualization
+type editRenderer struct {
+	baseRenderer
+}
+
+// Render displays the edited file with a formatted diff of changes
+func (er editRenderer) Render(v *toolCallCmp) string {
+	var params tools.EditParams
+	if err := er.unmarshalParams(v.call.Input, &params); err != nil {
+		return er.renderError(v, "Invalid edit parameters")
+	}
+
+	file := fileutil.PrettyPath(params.FilePath)
+	args := newParamBuilder().addMain(file).build()
+
+	return er.renderWithParams(v, "Edit", args, func() string {
+		var meta tools.EditResponseMetadata
+		if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
+			return renderPlainContent(v, v.result.Content)
+		}
+
+		trunc := truncateHeight(meta.Diff, responseContextHeight)
+		diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()-2))
+		return diffView
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  Write renderer
+// -----------------------------------------------------------------------------
+
+// writeRenderer handles file writing with syntax-highlighted content preview
+type writeRenderer struct {
+	baseRenderer
+}
+
+// Render displays the file being written with syntax highlighting
+func (wr writeRenderer) Render(v *toolCallCmp) string {
+	var params tools.WriteParams
+	if err := wr.unmarshalParams(v.call.Input, &params); err != nil {
+		return wr.renderError(v, "Invalid write parameters")
+	}
+
+	file := fileutil.PrettyPath(params.FilePath)
+	args := newParamBuilder().addMain(file).build()
+
+	return wr.renderWithParams(v, "Write", args, func() string {
+		return renderCodeContent(v, file, params.Content, 0)
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  Fetch renderer
+// -----------------------------------------------------------------------------
+
+// fetchRenderer handles URL fetching with format-specific content display
+type fetchRenderer struct {
+	baseRenderer
+}
+
+// Render displays the fetched URL with format and timeout parameters
+func (fr fetchRenderer) Render(v *toolCallCmp) string {
+	var params tools.FetchParams
+	if err := fr.unmarshalParams(v.call.Input, &params); err != nil {
+		return fr.renderError(v, "Invalid fetch parameters")
+	}
+
+	args := newParamBuilder().
+		addMain(params.URL).
+		addKeyValue("format", params.Format).
+		addKeyValue("timeout", formatTimeout(params.Timeout)).
+		build()
+
+	return fr.renderWithParams(v, "Fetch", args, func() string {
+		file := fr.getFileExtension(params.Format)
+		return renderCodeContent(v, file, v.result.Content, 0)
+	})
+}
+
+// getFileExtension returns appropriate file extension for syntax highlighting
+func (fr fetchRenderer) getFileExtension(format string) string {
+	switch format {
+	case "text":
+		return "fetch.txt"
+	case "html":
+		return "fetch.html"
+	default:
+		return "fetch.md"
+	}
+}
+
+// formatTimeout converts timeout seconds to duration string
+func formatTimeout(timeout int) string {
+	if timeout == 0 {
+		return ""
+	}
+	return (time.Duration(timeout) * time.Second).String()
+}
+
+// -----------------------------------------------------------------------------
+//  Glob renderer
+// -----------------------------------------------------------------------------
+
+// globRenderer handles file pattern matching with path filtering
+type globRenderer struct {
+	baseRenderer
+}
+
+// Render displays the glob pattern with optional path parameter
+func (gr globRenderer) Render(v *toolCallCmp) string {
+	var params tools.GlobParams
+	if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
+		return gr.renderError(v, "Invalid glob parameters")
+	}
+
+	args := newParamBuilder().
+		addMain(params.Pattern).
+		addKeyValue("path", params.Path).
+		build()
+
+	return gr.renderWithParams(v, "Glob", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  Grep renderer
+// -----------------------------------------------------------------------------
+
+// grepRenderer handles content searching with pattern matching options
+type grepRenderer struct {
+	baseRenderer
+}
+
+// Render displays the search pattern with path, include, and literal text options
+func (gr grepRenderer) Render(v *toolCallCmp) string {
+	var params tools.GrepParams
+	if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
+		return gr.renderError(v, "Invalid grep parameters")
+	}
+
+	args := newParamBuilder().
+		addMain(params.Pattern).
+		addKeyValue("path", params.Path).
+		addKeyValue("include", params.Include).
+		addFlag("literal", params.LiteralText).
+		build()
+
+	return gr.renderWithParams(v, "Grep", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  LS renderer
+// -----------------------------------------------------------------------------
+
+// lsRenderer handles directory listing with default path handling
+type lsRenderer struct {
+	baseRenderer
+}
+
+// Render displays the directory path, defaulting to current directory
+func (lr lsRenderer) Render(v *toolCallCmp) string {
+	var params tools.LSParams
+	if err := lr.unmarshalParams(v.call.Input, &params); err != nil {
+		return lr.renderError(v, "Invalid ls parameters")
+	}
+
+	path := params.Path
+	if path == "" {
+		path = "."
+	}
+	path = fileutil.PrettyPath(path)
+
+	args := newParamBuilder().addMain(path).build()
+
+	return lr.renderWithParams(v, "List", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  Sourcegraph renderer
+// -----------------------------------------------------------------------------
+
+// sourcegraphRenderer handles code search with count and context options
+type sourcegraphRenderer struct {
+	baseRenderer
+}
+
+// Render displays the search query with optional count and context window parameters
+func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
+	var params tools.SourcegraphParams
+	if err := sr.unmarshalParams(v.call.Input, &params); err != nil {
+		return sr.renderError(v, "Invalid sourcegraph parameters")
+	}
+
+	args := newParamBuilder().
+		addMain(params.Query).
+		addKeyValue("count", formatNonZero(params.Count)).
+		addKeyValue("context", formatNonZero(params.ContextWindow)).
+		build()
+
+	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  Patch renderer
+// -----------------------------------------------------------------------------
+
+// patchRenderer handles multi-file patches with change summaries
+type patchRenderer struct {
+	baseRenderer
+}
+
+// Render displays patch summary with file count and change statistics
+func (pr patchRenderer) Render(v *toolCallCmp) string {
+	var params tools.PatchParams
+	if err := pr.unmarshalParams(v.call.Input, &params); err != nil {
+		return pr.renderError(v, "Invalid patch parameters")
+	}
+
+	args := newParamBuilder().addMain("multiple files").build()
+
+	return pr.renderWithParams(v, "Patch", args, func() string {
+		var meta tools.PatchResponseMetadata
+		if err := pr.unmarshalParams(v.result.Metadata, &meta); err != nil {
+			return renderPlainContent(v, v.result.Content)
+		}
+
+		summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
+			len(meta.FilesChanged), meta.Additions, meta.Removals)
+		filesList := strings.Join(meta.FilesChanged, "\n")
+
+		return renderPlainContent(v, summary+"\n\n"+filesList)
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  Diagnostics renderer
+// -----------------------------------------------------------------------------
+
+// diagnosticsRenderer handles project-wide diagnostic information
+type diagnosticsRenderer struct {
+	baseRenderer
+}
+
+// Render displays project diagnostics with plain content formatting
+func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
+	args := newParamBuilder().addMain("project").build()
+
+	return dr.renderWithParams(v, "Diagnostics", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
+}
+
+// -----------------------------------------------------------------------------
+//  Task renderer
+// -----------------------------------------------------------------------------
+
+// agentRenderer handles project-wide diagnostic information
+type agentRenderer struct {
+	baseRenderer
+}
+
+// Render displays agent task parameters and result content
+func (tr agentRenderer) Render(v *toolCallCmp) string {
+	var params agent.AgentParams
+	if err := tr.unmarshalParams(v.call.Input, &params); err != nil {
+		return tr.renderError(v, "Invalid task parameters")
+	}
+	prompt := params.Prompt
+	prompt = strings.ReplaceAll(prompt, "\n", " ")
+	args := newParamBuilder().addMain(prompt).build()
+
+	header := tr.makeHeader(v, "Task", v.textWidth(), args...)
+	t := tree.Root(header)
+
+	for _, call := range v.nestedToolCalls {
+		t.Child(call.View())
+	}
+
+	parts := []string{
+		t.Enumerator(tree.RoundedEnumerator).String(),
+	}
+	if v.result.ToolCallID == "" {
+		v.spinning = true
+		parts = append(parts, v.anim.View().String())
+	} else {
+		v.spinning = false
+	}
+
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		parts...,
+	)
+
+	if v.result.ToolCallID == "" {
+		return header
+	}
+
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
+// renderParamList renders params, params[0] (params[1]=params[2] ....)
+func renderParamList(paramsWidth int, params ...string) string {
+	if len(params) == 0 {
+		return ""
+	}
+	mainParam := params[0]
+	if len(mainParam) > paramsWidth {
+		mainParam = mainParam[:paramsWidth-3] + "..."
+	}
+
+	if len(params) == 1 {
+		return mainParam
+	}
+	otherParams := params[1:]
+	// create pairs of key/value
+	// if odd number of params, the last one is a key without value
+	if len(otherParams)%2 != 0 {
+		otherParams = append(otherParams, "")
+	}
+	parts := make([]string, 0, len(otherParams)/2)
+	for i := 0; i < len(otherParams); i += 2 {
+		key := otherParams[i]
+		value := otherParams[i+1]
+		if value == "" {
+			continue
+		}
+		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+	}
+
+	partsRendered := strings.Join(parts, ", ")
+	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
+	if remainingWidth < 30 {
+		// No space for the params, just show the main
+		return mainParam
+	}
+
+	if len(parts) > 0 {
+		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+	}
+
+	return ansi.Truncate(mainParam, paramsWidth, "...")
+}
+
+// earlyState returns immediately‑rendered error/cancelled/ongoing states.
+func earlyState(header string, v *toolCallCmp) (string, bool) {
+	t := styles.CurrentTheme()
+	message := ""
+	switch {
+	case v.result.IsError:
+		message = v.renderToolError()
+	case v.cancelled:
+		message = "Cancelled"
+	case v.result.ToolCallID == "":
+		message = "Waiting for tool to start..."
+	default:
+		return "", false
+	}
+
+	message = t.S().Base.PaddingLeft(2).Render(message)
+	return lipgloss.JoinVertical(lipgloss.Left, header, message), true
+}
+
+func joinHeaderBody(header, body string) string {
+	t := styles.CurrentTheme()
+	body = t.S().Base.PaddingLeft(2).Render(body)
+	return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
+}
+
+func renderPlainContent(v *toolCallCmp, content string) string {
+	t := styles.CurrentTheme()
+	content = strings.TrimSpace(content)
+	lines := strings.Split(content, "\n")
+
+	width := v.textWidth() - 2 // -2 for left padding
+	var out []string
+	for i, ln := range lines {
+		if i >= responseContextHeight {
+			break
+		}
+		ln = " " + ln // left padding
+		if len(ln) > width {
+			ln = v.fit(ln, width)
+		}
+		out = append(out, t.S().Muted.
+			Width(width).
+			Background(t.BgSubtle).
+			Render(ln))
+	}
+
+	if len(lines) > responseContextHeight {
+		out = append(out, t.S().Muted.
+			Background(t.BgSubtle).
+			Width(width).
+			Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
+	}
+	return strings.Join(out, "\n")
+}
+
+func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
+	t := styles.CurrentTheme()
+	truncated := truncateHeight(content, responseContextHeight)
+
+	highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgSubtle)
+	lines := strings.Split(highlighted, "\n")
+
+	if len(strings.Split(content, "\n")) > responseContextHeight {
+		lines = append(lines, t.S().Muted.
+			Background(t.BgSubtle).
+			Width(v.textWidth()-2).
+			Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
+	}
+
+	for i, ln := range lines {
+		num := t.S().Muted.
+			Background(t.BgSubtle).
+			PaddingLeft(4).
+			PaddingRight(2).
+			Render(fmt.Sprintf("%d", i+1+offset))
+		w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
+		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
+			num,
+			t.S().Base.
+				Width(w).
+				Background(t.BgSubtle).
+				Render(v.fit(ln, w)))
+	}
+	return lipgloss.JoinVertical(lipgloss.Left, lines...)
+}
+
+func (v *toolCallCmp) renderToolError() string {
+	t := styles.CurrentTheme()
+	err := strings.ReplaceAll(v.result.Content, "\n", " ")
+	err = fmt.Sprintf("Error: %s", err)
+	return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
+}
+
+func truncateHeight(s string, h int) string {
+	lines := strings.Split(s, "\n")
+	if len(lines) > h {
+		return strings.Join(lines[:h], "\n")
+	}
+	return s
+}
+
+func prettifyToolName(name string) string {
+	switch name {
+	case agent.AgentToolName:
+		return "Task"
+	case tools.BashToolName:
+		return "Bash"
+	case tools.EditToolName:
+		return "Edit"
+	case tools.FetchToolName:
+		return "Fetch"
+	case tools.GlobToolName:
+		return "Glob"
+	case tools.GrepToolName:
+		return "Grep"
+	case tools.LSToolName:
+		return "List"
+	case tools.SourcegraphToolName:
+		return "Sourcegraph"
+	case tools.ViewToolName:
+		return "View"
+	case tools.WriteToolName:
+		return "Write"
+	case tools.PatchToolName:
+		return "Patch"
+	default:
+		return name
+	}
+}

internal/tui/components/chat/messages/tool.go 🔗

@@ -0,0 +1,295 @@
+package messages
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/bubbles/v2/spinner"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/tui/components/anim"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// ToolCallCmp defines the interface for tool call components in the chat interface.
+// It manages the display of tool execution including pending states, results, and errors.
+type ToolCallCmp interface {
+	util.Model                         // Basic Bubble Tea model interface
+	layout.Sizeable                    // Width/height management
+	layout.Focusable                   // Focus state management
+	GetToolCall() message.ToolCall     // Access to tool call data
+	GetToolResult() message.ToolResult // Access to tool result data
+	SetToolResult(message.ToolResult)  // Update tool result
+	SetToolCall(message.ToolCall)      // Update tool call
+	SetCancelled()                     // Mark as cancelled
+	ParentMessageId() string           // Get parent message ID
+	Spinning() bool                    // Animation state for pending tools
+	GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
+	SetNestedToolCalls([]ToolCallCmp)  // Set nested tool calls
+	SetIsNested(bool)                  // Set whether this tool call is nested
+}
+
+// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
+// It handles rendering of tool execution states including pending, completed, and error states.
+type toolCallCmp struct {
+	width    int  // Component width for text wrapping
+	focused  bool // Focus state for border styling
+	isNested bool // Whether this tool call is nested within another
+
+	// Tool call data and state
+	parentMessageID string             // ID of the message that initiated this tool call
+	call            message.ToolCall   // The tool call being executed
+	result          message.ToolResult // The result of the tool execution
+	cancelled       bool               // Whether the tool call was cancelled
+
+	// Animation state for pending tool calls
+	spinning bool       // Whether to show loading animation
+	anim     util.Model // Animation component for pending states
+
+	nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
+}
+
+// ToolCallOption provides functional options for configuring tool call components
+type ToolCallOption func(*toolCallCmp)
+
+// WithToolCallCancelled marks the tool call as cancelled
+func WithToolCallCancelled() ToolCallOption {
+	return func(m *toolCallCmp) {
+		m.cancelled = true
+	}
+}
+
+// WithToolCallResult sets the initial tool result
+func WithToolCallResult(result message.ToolResult) ToolCallOption {
+	return func(m *toolCallCmp) {
+		m.result = result
+	}
+}
+
+func WithToolCallNested(isNested bool) ToolCallOption {
+	return func(m *toolCallCmp) {
+		m.isNested = isNested
+	}
+}
+
+func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
+	return func(m *toolCallCmp) {
+		m.nestedToolCalls = calls
+	}
+}
+
+// NewToolCallCmp creates a new tool call component with the given parent message ID,
+// tool call, and optional configuration
+func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
+	m := &toolCallCmp{
+		call:            tc,
+		parentMessageID: parentMessageId,
+	}
+	for _, opt := range opts {
+		opt(m)
+	}
+	m.anim = anim.New(15, "Working")
+	if m.isNested {
+		m.anim = anim.New(10, "")
+	}
+	return m
+}
+
+// Init initializes the tool call component and starts animations if needed.
+// Returns a command to start the animation for pending tool calls.
+func (m *toolCallCmp) Init() tea.Cmd {
+	m.spinning = m.shouldSpin()
+	if m.spinning {
+		return m.anim.Init()
+	}
+	return nil
+}
+
+// Update handles incoming messages and updates the component state.
+// Manages animation updates for pending tool calls.
+func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
+		var cmds []tea.Cmd
+		for i, nested := range m.nestedToolCalls {
+			if nested.Spinning() {
+				u, cmd := nested.Update(msg)
+				m.nestedToolCalls[i] = u.(ToolCallCmp)
+				cmds = append(cmds, cmd)
+			}
+		}
+		if m.spinning {
+			u, cmd := m.anim.Update(msg)
+			m.anim = u.(util.Model)
+			cmds = append(cmds, cmd)
+		}
+		return m, tea.Batch(cmds...)
+	}
+	return m, nil
+}
+
+// View renders the tool call component based on its current state.
+// Shows either a pending animation or the tool-specific rendered result.
+func (m *toolCallCmp) View() tea.View {
+	box := m.style()
+
+	if !m.call.Finished && !m.cancelled {
+		if m.isNested {
+			return tea.NewView(box.Render(m.renderPending()))
+		}
+		return tea.NewView(box.Render(m.renderPending()))
+	}
+
+	r := registry.lookup(m.call.Name)
+
+	if m.isNested {
+		return tea.NewView(box.Render(r.Render(m)))
+	}
+	return tea.NewView(box.Render(r.Render(m)))
+}
+
+// State management methods
+
+// SetCancelled marks the tool call as cancelled
+func (m *toolCallCmp) SetCancelled() {
+	m.cancelled = true
+}
+
+// SetToolCall updates the tool call data and stops spinning if finished
+func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
+	m.call = call
+	if m.call.Finished {
+		m.spinning = false
+	}
+}
+
+// ParentMessageId returns the ID of the message that initiated this tool call
+func (m *toolCallCmp) ParentMessageId() string {
+	return m.parentMessageID
+}
+
+// SetToolResult updates the tool result and stops the spinning animation
+func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
+	m.result = result
+	m.spinning = false
+}
+
+// GetToolCall returns the current tool call data
+func (m *toolCallCmp) GetToolCall() message.ToolCall {
+	return m.call
+}
+
+// GetToolResult returns the current tool result data
+func (m *toolCallCmp) GetToolResult() message.ToolResult {
+	return m.result
+}
+
+// GetNestedToolCalls returns the nested tool calls
+func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
+	return m.nestedToolCalls
+}
+
+// SetNestedToolCalls sets the nested tool calls
+func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
+	m.nestedToolCalls = calls
+	for _, nested := range m.nestedToolCalls {
+		nested.SetSize(m.width, 0)
+	}
+}
+
+// SetIsNested sets whether this tool call is nested within another
+func (m *toolCallCmp) SetIsNested(isNested bool) {
+	m.isNested = isNested
+}
+
+// Rendering methods
+
+// renderPending displays the tool name with a loading animation for pending tool calls
+func (m *toolCallCmp) renderPending() string {
+	t := styles.CurrentTheme()
+	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
+	tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
+	return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
+}
+
+// style returns the lipgloss style for the tool call component.
+// Applies muted colors and focus-dependent border styles.
+func (m *toolCallCmp) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+
+	if m.isNested {
+		return t.S().Muted
+	}
+	return t.S().Muted.PaddingLeft(4)
+}
+
+// textWidth calculates the available width for text content,
+// accounting for borders and padding
+func (m *toolCallCmp) textWidth() int {
+	return m.width - 5 // take into account the border and PaddingLeft
+}
+
+// fit truncates content to fit within the specified width with ellipsis
+func (m *toolCallCmp) fit(content string, width int) string {
+	t := styles.CurrentTheme()
+	lineStyle := t.S().Muted.Background(t.BgSubtle)
+	dots := lineStyle.Render("...")
+	return ansi.Truncate(content, width, dots)
+}
+
+// Focus management methods
+
+// Blur removes focus from the tool call component
+func (m *toolCallCmp) Blur() tea.Cmd {
+	m.focused = false
+	return nil
+}
+
+// Focus sets focus on the tool call component
+func (m *toolCallCmp) Focus() tea.Cmd {
+	m.focused = true
+	return nil
+}
+
+// IsFocused returns whether the tool call component is currently focused
+func (m *toolCallCmp) IsFocused() bool {
+	return m.focused
+}
+
+// Size management methods
+
+// GetSize returns the current dimensions of the tool call component
+func (m *toolCallCmp) GetSize() (int, int) {
+	return m.width, 0
+}
+
+// SetSize updates the width of the tool call component for text wrapping
+func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	for _, nested := range m.nestedToolCalls {
+		nested.SetSize(width, height)
+	}
+	return nil
+}
+
+// shouldSpin determines whether the tool call should show a loading animation.
+// Returns true if the tool call is not finished or if the result doesn't match the call ID.
+func (m *toolCallCmp) shouldSpin() bool {
+	return !m.call.Finished
+}
+
+// Spinning returns whether the tool call is currently showing a loading animation
+func (m *toolCallCmp) Spinning() bool {
+	if m.spinning {
+		return true
+	}
+	for _, nested := range m.nestedToolCalls {
+		if nested.Spinning() {
+			return true
+		}
+	}
+	return m.spinning
+}

internal/tui/components/chat/sidebar.go 🔗

@@ -1,378 +0,0 @@
-package chat
-
-import (
-	"context"
-	"fmt"
-	"sort"
-	"strings"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/diff"
-	"github.com/opencode-ai/opencode/internal/history"
-	"github.com/opencode-ai/opencode/internal/pubsub"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-)
-
-type sidebarCmp struct {
-	width, height int
-	session       session.Session
-	history       history.Service
-	modFiles      map[string]struct {
-		additions int
-		removals  int
-	}
-}
-
-func (m *sidebarCmp) Init() tea.Cmd {
-	if m.history != nil {
-		ctx := context.Background()
-		// Subscribe to file events
-		filesCh := m.history.Subscribe(ctx)
-
-		// Initialize the modified files map
-		m.modFiles = make(map[string]struct {
-			additions int
-			removals  int
-		})
-
-		// Load initial files and calculate diffs
-		m.loadModifiedFiles(ctx)
-
-		// Return a command that will send file events to the Update method
-		return func() tea.Msg {
-			return <-filesCh
-		}
-	}
-	return nil
-}
-
-func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case SessionSelectedMsg:
-		if msg.ID != m.session.ID {
-			m.session = msg
-			ctx := context.Background()
-			m.loadModifiedFiles(ctx)
-		}
-	case pubsub.Event[session.Session]:
-		if msg.Type == pubsub.UpdatedEvent {
-			if m.session.ID == msg.Payload.ID {
-				m.session = msg.Payload
-			}
-		}
-	case pubsub.Event[history.File]:
-		if msg.Payload.SessionID == m.session.ID {
-			// Process the individual file change instead of reloading all files
-			ctx := context.Background()
-			m.processFileChanges(ctx, msg.Payload)
-
-			// Return a command to continue receiving events
-			return m, func() tea.Msg {
-				ctx := context.Background()
-				filesCh := m.history.Subscribe(ctx)
-				return <-filesCh
-			}
-		}
-	}
-	return m, nil
-}
-
-func (m *sidebarCmp) View() string {
-	baseStyle := styles.BaseStyle()
-
-	return baseStyle.
-		Width(m.width).
-		PaddingLeft(4).
-		PaddingRight(2).
-		Height(m.height - 1).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Top,
-				header(m.width),
-				" ",
-				m.sessionSection(),
-				" ",
-				lspsConfigured(m.width),
-				" ",
-				m.modifiedFiles(),
-			),
-		)
-}
-
-func (m *sidebarCmp) sessionSection() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	sessionKey := baseStyle.
-		Foreground(t.Primary()).
-		Bold(true).
-		Render("Session")
-
-	sessionValue := baseStyle.
-		Foreground(t.Text()).
-		Width(m.width - lipgloss.Width(sessionKey)).
-		Render(fmt.Sprintf(": %s", m.session.Title))
-
-	return lipgloss.JoinHorizontal(
-		lipgloss.Left,
-		sessionKey,
-		sessionValue,
-	)
-}
-
-func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	stats := ""
-	if additions > 0 && removals > 0 {
-		additionsStr := baseStyle.
-			Foreground(t.Success()).
-			PaddingLeft(1).
-			Render(fmt.Sprintf("+%d", additions))
-
-		removalsStr := baseStyle.
-			Foreground(t.Error()).
-			PaddingLeft(1).
-			Render(fmt.Sprintf("-%d", removals))
-
-		content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
-		stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
-	} else if additions > 0 {
-		additionsStr := fmt.Sprintf(" %s", baseStyle.
-			PaddingLeft(1).
-			Foreground(t.Success()).
-			Render(fmt.Sprintf("+%d", additions)))
-		stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
-	} else if removals > 0 {
-		removalsStr := fmt.Sprintf(" %s", baseStyle.
-			PaddingLeft(1).
-			Foreground(t.Error()).
-			Render(fmt.Sprintf("-%d", removals)))
-		stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
-	}
-
-	filePathStr := baseStyle.Render(filePath)
-
-	return baseStyle.
-		Width(m.width).
-		Render(
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				filePathStr,
-				stats,
-			),
-		)
-}
-
-func (m *sidebarCmp) modifiedFiles() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	modifiedFiles := baseStyle.
-		Width(m.width).
-		Foreground(t.Primary()).
-		Bold(true).
-		Render("Modified Files:")
-
-	// If no modified files, show a placeholder message
-	if m.modFiles == nil || len(m.modFiles) == 0 {
-		message := "No modified files"
-		remainingWidth := m.width - lipgloss.Width(message)
-		if remainingWidth > 0 {
-			message += strings.Repeat(" ", remainingWidth)
-		}
-		return baseStyle.
-			Width(m.width).
-			Render(
-				lipgloss.JoinVertical(
-					lipgloss.Top,
-					modifiedFiles,
-					baseStyle.Foreground(t.TextMuted()).Render(message),
-				),
-			)
-	}
-
-	// Sort file paths alphabetically for consistent ordering
-	var paths []string
-	for path := range m.modFiles {
-		paths = append(paths, path)
-	}
-	sort.Strings(paths)
-
-	// Create views for each file in sorted order
-	var fileViews []string
-	for _, path := range paths {
-		stats := m.modFiles[path]
-		fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
-	}
-
-	return baseStyle.
-		Width(m.width).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Top,
-				modifiedFiles,
-				lipgloss.JoinVertical(
-					lipgloss.Left,
-					fileViews...,
-				),
-			),
-		)
-}
-
-func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
-	m.width = width
-	m.height = height
-	return nil
-}
-
-func (m *sidebarCmp) GetSize() (int, int) {
-	return m.width, m.height
-}
-
-func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
-	return &sidebarCmp{
-		session: session,
-		history: history,
-	}
-}
-
-func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
-	if m.history == nil || m.session.ID == "" {
-		return
-	}
-
-	// Get all latest files for this session
-	latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
-	if err != nil {
-		return
-	}
-
-	// Get all files for this session (to find initial versions)
-	allFiles, err := m.history.ListBySession(ctx, m.session.ID)
-	if err != nil {
-		return
-	}
-
-	// Clear the existing map to rebuild it
-	m.modFiles = make(map[string]struct {
-		additions int
-		removals  int
-	})
-
-	// Process each latest file
-	for _, file := range latestFiles {
-		// Skip if this is the initial version (no changes to show)
-		if file.Version == history.InitialVersion {
-			continue
-		}
-
-		// Find the initial version for this specific file
-		var initialVersion history.File
-		for _, v := range allFiles {
-			if v.Path == file.Path && v.Version == history.InitialVersion {
-				initialVersion = v
-				break
-			}
-		}
-
-		// Skip if we can't find the initial version
-		if initialVersion.ID == "" {
-			continue
-		}
-		if initialVersion.Content == file.Content {
-			continue
-		}
-
-		// Calculate diff between initial and latest version
-		_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
-
-		// Only add to modified files if there are changes
-		if additions > 0 || removals > 0 {
-			// Remove working directory prefix from file path
-			displayPath := file.Path
-			workingDir := config.WorkingDirectory()
-			displayPath = strings.TrimPrefix(displayPath, workingDir)
-			displayPath = strings.TrimPrefix(displayPath, "/")
-
-			m.modFiles[displayPath] = struct {
-				additions int
-				removals  int
-			}{
-				additions: additions,
-				removals:  removals,
-			}
-		}
-	}
-}
-
-func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
-	// Skip if this is the initial version (no changes to show)
-	if file.Version == history.InitialVersion {
-		return
-	}
-
-	// Find the initial version for this file
-	initialVersion, err := m.findInitialVersion(ctx, file.Path)
-	if err != nil || initialVersion.ID == "" {
-		return
-	}
-
-	// Skip if content hasn't changed
-	if initialVersion.Content == file.Content {
-		// If this file was previously modified but now matches the initial version,
-		// remove it from the modified files list
-		displayPath := getDisplayPath(file.Path)
-		delete(m.modFiles, displayPath)
-		return
-	}
-
-	// Calculate diff between initial and latest version
-	_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
-
-	// Only add to modified files if there are changes
-	if additions > 0 || removals > 0 {
-		displayPath := getDisplayPath(file.Path)
-		m.modFiles[displayPath] = struct {
-			additions int
-			removals  int
-		}{
-			additions: additions,
-			removals:  removals,
-		}
-	} else {
-		// If no changes, remove from modified files
-		displayPath := getDisplayPath(file.Path)
-		delete(m.modFiles, displayPath)
-	}
-}
-
-// Helper function to find the initial version of a file
-func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
-	// Get all versions of this file for the session
-	fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
-	if err != nil {
-		return history.File{}, err
-	}
-
-	// Find the initial version
-	for _, v := range fileVersions {
-		if v.Path == path && v.Version == history.InitialVersion {
-			return v, nil
-		}
-	}
-
-	return history.File{}, fmt.Errorf("initial version not found")
-}
-
-// Helper function to get the display path for a file
-func getDisplayPath(path string) string {
-	workingDir := config.WorkingDirectory()
-	displayPath := strings.TrimPrefix(path, workingDir)
-	return strings.TrimPrefix(displayPath, "/")
-}

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -0,0 +1,207 @@
+package sidebar
+
+import (
+	"os"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/chat"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/logo"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/crush/internal/version"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+const (
+	logoBreakpoint = 65
+)
+
+type Sidebar interface {
+	util.Model
+	layout.Sizeable
+}
+
+type sidebarCmp struct {
+	width, height int
+	session       session.Session
+	logo          string
+	cwd           string
+}
+
+func NewSidebarCmp() Sidebar {
+	return &sidebarCmp{}
+}
+
+func (m *sidebarCmp) Init() tea.Cmd {
+	m.logo = m.logoBlock(false)
+	m.cwd = cwd()
+	return nil
+}
+
+func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case chat.SessionSelectedMsg:
+		if msg.ID != m.session.ID {
+			m.session = msg
+		}
+	case chat.SessionClearedMsg:
+		m.session = session.Session{}
+	case pubsub.Event[session.Session]:
+		if msg.Type == pubsub.UpdatedEvent {
+			if m.session.ID == msg.Payload.ID {
+				m.session = msg.Payload
+			}
+		}
+	}
+	return m, nil
+}
+
+func (m *sidebarCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	parts := []string{
+		m.logo,
+	}
+
+	if m.session.ID != "" {
+		parts = append(parts, t.S().Muted.Render(m.session.Title), "")
+	}
+
+	parts = append(parts,
+		m.cwd,
+		"",
+		m.lspBlock(),
+		"",
+		m.mcpBlock(),
+	)
+
+	return tea.NewView(
+		lipgloss.JoinVertical(lipgloss.Left, parts...),
+	)
+}
+
+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
+	if width < logoBreakpoint && m.width >= logoBreakpoint {
+		m.logo = m.logoBlock(true)
+	} else if width >= logoBreakpoint && m.width < logoBreakpoint {
+		m.logo = m.logoBlock(false)
+	}
+
+	m.width = width
+	m.height = height
+	return nil
+}
+
+func (m *sidebarCmp) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+func (m *sidebarCmp) logoBlock(compact bool) string {
+	t := styles.CurrentTheme()
+	return logo.Render(version.Version, compact, logo.Opts{
+		FieldColor:   t.Primary,
+		TitleColorA:  t.Secondary,
+		TitleColorB:  t.Primary,
+		CharmColor:   t.Secondary,
+		VersionColor: t.Primary,
+	})
+}
+
+func (m *sidebarCmp) lspBlock() string {
+	maxWidth := min(m.width, 58)
+	t := styles.CurrentTheme()
+
+	section := t.S().Muted.Render(
+		core.Section("LSPs", maxWidth),
+	)
+
+	lspList := []string{section, ""}
+
+	lsp := config.Get().LSP
+	if len(lsp) == 0 {
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Base.Foreground(t.Border).Render("None"),
+		)
+	}
+
+	for n, l := range lsp {
+		iconColor := t.Success
+		if l.Disabled {
+			iconColor = t.FgMuted
+		}
+		lspList = append(lspList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:   iconColor,
+					Title:       n,
+					Description: l.Command,
+				},
+				m.width,
+			),
+		)
+	}
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		lspList...,
+	)
+}
+
+func (m *sidebarCmp) mcpBlock() string {
+	maxWidth := min(m.width, 58)
+	t := styles.CurrentTheme()
+
+	section := t.S().Muted.Render(
+		core.Section("MCPs", maxWidth),
+	)
+
+	mcpList := []string{section, ""}
+
+	mcp := config.Get().MCPServers
+	if len(mcp) == 0 {
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Base.Foreground(t.Border).Render("None"),
+		)
+	}
+
+	for n, l := range mcp {
+		iconColor := t.Success
+		mcpList = append(mcpList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:   iconColor,
+					Title:       n,
+					Description: l.Command,
+				},
+				m.width,
+			),
+		)
+	}
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		mcpList...,
+	)
+}
+
+func cwd() string {
+	cwd := config.WorkingDirectory()
+	t := styles.CurrentTheme()
+	// replace home directory with ~
+	homeDir, err := os.UserHomeDir()
+	if err == nil {
+		cwd = strings.ReplaceAll(cwd, homeDir, "~")
+	}
+	return t.S().Muted.Render(cwd)
+}

internal/tui/components/completions/completions.go 🔗

@@ -0,0 +1,195 @@
+package completions
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+type Completion struct {
+	Title string // The title of the completion item
+	Value any    // The value of the completion item
+}
+
+type OpenCompletionsMsg struct {
+	Completions []Completion
+	X           int // X position for the completions popup
+	Y           int // Y position for the completions popup
+}
+
+type FilterCompletionsMsg struct {
+	Query string // The query to filter completions
+}
+
+type CompletionsClosedMsg struct{}
+
+type CloseCompletionsMsg struct{}
+
+type SelectCompletionMsg struct {
+	Value any // The value of the selected completion item
+}
+
+type Completions interface {
+	util.Model
+	Open() bool
+	Query() string // Returns the current filter query
+	KeyMap() KeyMap
+	Position() (int, int) // Returns the X and Y position of the completions popup
+}
+
+type completionsCmp struct {
+	width  int
+	height int  // Height of the completions component`
+	x      int  // X position for the completions popup\
+	y      int  // Y position for the completions popup
+	open   bool // Indicates if the completions are open
+	keyMap KeyMap
+
+	list  list.ListModel
+	query string // The current filter query
+}
+
+func New() Completions {
+	completionsKeyMap := DefaultKeyMap()
+	keyMap := list.DefaultKeyMap()
+	keyMap.Up.SetEnabled(false)
+	keyMap.Down.SetEnabled(false)
+	keyMap.NDown.SetEnabled(false)
+	keyMap.NUp.SetEnabled(false)
+	keyMap.HalfPageDown.SetEnabled(false)
+	keyMap.HalfPageUp.SetEnabled(false)
+	keyMap.Home.SetEnabled(false)
+	keyMap.End.SetEnabled(false)
+	keyMap.UpOneItem = completionsKeyMap.Up
+	keyMap.DownOneItem = completionsKeyMap.Down
+
+	l := list.New(
+		list.WithReverse(true),
+		list.WithKeyMap(keyMap),
+		list.WithHideFilterInput(true),
+	)
+	return &completionsCmp{
+		width:  30,
+		height: 10,
+		list:   l,
+		query:  "",
+		keyMap: completionsKeyMap,
+	}
+}
+
+// Init implements Completions.
+func (c *completionsCmp) Init() tea.Cmd {
+	return tea.Sequence(
+		c.list.Init(),
+		c.list.SetSize(c.width, c.height),
+	)
+}
+
+// Update implements Completions.
+func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, c.keyMap.Up):
+			u, cmd := c.list.Update(msg)
+			c.list = u.(list.ListModel)
+			return c, cmd
+
+		case key.Matches(msg, c.keyMap.Down):
+			d, cmd := c.list.Update(msg)
+			c.list = d.(list.ListModel)
+			return c, cmd
+		case key.Matches(msg, c.keyMap.Select):
+			selectedItemInx := c.list.SelectedIndex()
+			if selectedItemInx == list.NoSelection {
+				return c, nil // No item selected, do nothing
+			}
+			items := c.list.Items()
+			selectedItem := items[selectedItemInx].(CompletionItem).Value()
+			c.open = false // Close completions after selection
+			return c, util.CmdHandler(SelectCompletionMsg{
+				Value: selectedItem,
+			})
+		case key.Matches(msg, c.keyMap.Cancel):
+			if c.open {
+				c.open = false
+				return c, util.CmdHandler(CompletionsClosedMsg{})
+			}
+		}
+	case CloseCompletionsMsg:
+		c.open = false
+		c.query = ""
+		return c, tea.Batch(
+			c.list.SetItems([]util.Model{}),
+			util.CmdHandler(CompletionsClosedMsg{}),
+		)
+	case OpenCompletionsMsg:
+		c.open = true
+		c.query = ""
+		c.x = msg.X
+		c.y = msg.Y
+		items := []util.Model{}
+		t := styles.CurrentTheme()
+		for _, completion := range msg.Completions {
+			item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
+			items = append(items, item)
+		}
+		c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height
+		cmds := []tea.Cmd{
+			c.list.SetSize(c.width, c.height),
+			c.list.SetItems(items),
+		}
+		return c, tea.Batch(cmds...)
+	case FilterCompletionsMsg:
+		c.query = msg.Query
+		if !c.open {
+			return c, nil // If completions are not open, do nothing
+		}
+		cmd := c.list.Filter(msg.Query)
+		c.height = max(min(10, len(c.list.Items())), 1)
+		return c, tea.Batch(
+			cmd,
+			c.list.SetSize(c.width, c.height),
+		)
+	}
+	return c, nil
+}
+
+// View implements Completions.
+func (c *completionsCmp) View() tea.View {
+	if len(c.list.Items()) == 0 {
+		return tea.NewView(c.style().Render("No completions found"))
+	}
+
+	view := tea.NewView(
+		c.style().Render(c.list.View().String()),
+	)
+	return view
+}
+
+func (c *completionsCmp) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+	return t.S().Base.
+		Width(c.width).
+		Height(c.height).
+		Background(t.BgSubtle)
+}
+
+func (c *completionsCmp) Open() bool {
+	return c.open
+}
+
+func (c *completionsCmp) Query() string {
+	return c.query
+}
+
+func (c *completionsCmp) KeyMap() KeyMap {
+	return c.keyMap
+}
+
+func (c *completionsCmp) Position() (int, int) {
+	return c.x, c.y - c.height
+}

internal/tui/components/completions/item.go 🔗

@@ -0,0 +1,281 @@
+package completions
+
+import (
+	"image/color"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/rivo/uniseg"
+)
+
+type CompletionItem interface {
+	util.Model
+	layout.Focusable
+	layout.Sizeable
+	list.HasMatchIndexes
+	list.HasFilterValue
+	Value() any
+}
+
+type completionItemCmp struct {
+	width        int
+	text         string
+	value        any
+	focus        bool
+	matchIndexes []int
+	bgColor      color.Color
+	shortcut     string
+}
+
+type CompletionOption func(*completionItemCmp)
+
+func WithBackgroundColor(c color.Color) CompletionOption {
+	return func(cmp *completionItemCmp) {
+		cmp.bgColor = c
+	}
+}
+
+func WithMatchIndexes(indexes ...int) CompletionOption {
+	return func(cmp *completionItemCmp) {
+		cmp.matchIndexes = indexes
+	}
+}
+
+func WithShortcut(shortcut string) CompletionOption {
+	return func(cmp *completionItemCmp) {
+		cmp.shortcut = shortcut
+	}
+}
+
+func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
+	c := &completionItemCmp{
+		text:  text,
+		value: value,
+	}
+
+	for _, opt := range opts {
+		opt(c)
+	}
+	return c
+}
+
+// Init implements CommandItem.
+func (c *completionItemCmp) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements CommandItem.
+func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return c, nil
+}
+
+// View implements CommandItem.
+func (c *completionItemCmp) View() tea.View {
+	t := styles.CurrentTheme()
+
+	itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
+	innerWidth := c.width - 2 // Account for padding
+
+	if c.shortcut != "" {
+		innerWidth -= lipgloss.Width(c.shortcut)
+	}
+
+	titleStyle := t.S().Text.Width(innerWidth)
+	titleMatchStyle := t.S().Text.Underline(true)
+	if c.bgColor != nil {
+		titleStyle = titleStyle.Background(c.bgColor)
+		titleMatchStyle = titleMatchStyle.Background(c.bgColor)
+	}
+
+	if c.focus {
+		titleStyle = t.S().TextSelected.Width(innerWidth)
+		titleMatchStyle = t.S().TextSelected.Underline(true)
+		itemStyle = itemStyle.Background(t.Primary)
+	}
+
+	var truncatedTitle string
+
+	if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
+		// Smart truncation: ensure the last matching part is visible
+		truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
+	} else {
+		// No matches, use regular truncation
+		truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
+	}
+
+	text := titleStyle.Render(truncatedTitle)
+	if len(c.matchIndexes) > 0 {
+		var ranges []lipgloss.Range
+		for _, rng := range matchedRanges(c.matchIndexes) {
+			// ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
+			// all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
+			// so we need to adjust it here:
+			start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
+			ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
+		}
+		text = lipgloss.StyleRanges(text, ranges...)
+	}
+	parts := []string{text}
+	if c.shortcut != "" {
+		// Add the shortcut at the end
+		shortcutStyle := t.S().Muted
+		if c.focus {
+			shortcutStyle = t.S().TextSelected
+		}
+		parts = append(parts, shortcutStyle.Render(c.shortcut))
+	}
+	item := itemStyle.Render(
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			parts...,
+		),
+	)
+	return tea.NewView(item)
+}
+
+// Blur implements CommandItem.
+func (c *completionItemCmp) Blur() tea.Cmd {
+	c.focus = false
+	return nil
+}
+
+// Focus implements CommandItem.
+func (c *completionItemCmp) Focus() tea.Cmd {
+	c.focus = true
+	return nil
+}
+
+// GetSize implements CommandItem.
+func (c *completionItemCmp) GetSize() (int, int) {
+	return c.width, 1
+}
+
+// IsFocused implements CommandItem.
+func (c *completionItemCmp) IsFocused() bool {
+	return c.focus
+}
+
+// SetSize implements CommandItem.
+func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
+	c.width = width
+	return nil
+}
+
+func (c *completionItemCmp) MatchIndexes(indexes []int) {
+	c.matchIndexes = indexes
+}
+
+func (c *completionItemCmp) FilterValue() string {
+	return c.text
+}
+
+func (c *completionItemCmp) Value() any {
+	return c.value
+}
+
+// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
+func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
+	if width <= 0 {
+		return ""
+	}
+
+	textLen := ansi.StringWidth(text)
+	if textLen <= width {
+		return text
+	}
+
+	if len(matchIndexes) == 0 {
+		return ansi.Truncate(text, width, "…")
+	}
+
+	// Find the last match position
+	lastMatchPos := matchIndexes[len(matchIndexes)-1]
+
+	// Convert byte position to visual width position
+	lastMatchVisualPos := 0
+	bytePos := 0
+	gr := uniseg.NewGraphemes(text)
+	for bytePos < lastMatchPos && gr.Next() {
+		bytePos += len(gr.Str())
+		lastMatchVisualPos += max(1, gr.Width())
+	}
+
+	// Calculate how much space we need for the ellipsis
+	ellipsisWidth := 1 // "…" character width
+	availableWidth := width - ellipsisWidth
+
+	// If the last match is within the available width, truncate from the end
+	if lastMatchVisualPos < availableWidth {
+		return ansi.Truncate(text, width, "…")
+	}
+
+	// Calculate the start position to ensure the last match is visible
+	// We want to show some context before the last match if possible
+	startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
+
+	// Convert visual position back to byte position
+	startBytePos := 0
+	currentVisualPos := 0
+	gr = uniseg.NewGraphemes(text)
+	for currentVisualPos < startVisualPos && gr.Next() {
+		startBytePos += len(gr.Str())
+		currentVisualPos += max(1, gr.Width())
+	}
+
+	// Extract the substring starting from startBytePos
+	truncatedText := text[startBytePos:]
+
+	// Truncate to fit width with ellipsis
+	truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
+	truncatedText = "…" + truncatedText
+	return truncatedText
+}
+
+func matchedRanges(in []int) [][2]int {
+	if len(in) == 0 {
+		return [][2]int{}
+	}
+	current := [2]int{in[0], in[0]}
+	if len(in) == 1 {
+		return [][2]int{current}
+	}
+	var out [][2]int
+	for i := 1; i < len(in); i++ {
+		if in[i] == current[1]+1 {
+			current[1] = in[i]
+		} else {
+			out = append(out, current)
+			current = [2]int{in[i], in[i]}
+		}
+	}
+	out = append(out, current)
+	return out
+}
+
+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
+	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
+	pos, start, stop := 0, 0, 0
+	gr := uniseg.NewGraphemes(str)
+	for byteStart > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	start = pos
+	for byteStop > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	stop = pos
+	return start, stop
+}

internal/tui/components/completions/keys.go 🔗

@@ -0,0 +1,53 @@
+package completions
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Down,
+	Up,
+	Select,
+	Cancel key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Down: key.NewBinding(
+			key.WithKeys("down"),
+			key.WithHelp("down", "move down"),
+		),
+		Up: key.NewBinding(
+			key.WithKeys("up"),
+			key.WithHelp("up", "move up"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "select"),
+		),
+		Cancel: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Up,
+		k.Down,
+	}
+}

internal/tui/components/core/helpers.go 🔗

@@ -0,0 +1,133 @@
+package core
+
+import (
+	"image/color"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+)
+
+func Section(text string, width int) string {
+	t := styles.CurrentTheme()
+	char := "─"
+	length := lipgloss.Width(text) + 1
+	remainingWidth := width - length
+	lineStyle := t.S().Base.Foreground(t.Border)
+	if remainingWidth > 0 {
+		text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
+	}
+	return text
+}
+
+func Title(title string, width int) string {
+	t := styles.CurrentTheme()
+	char := "╱"
+	length := lipgloss.Width(title) + 1
+	remainingWidth := width - length
+	titleStyle := t.S().Base.Foreground(t.Primary)
+	if remainingWidth > 0 {
+		lines := strings.Repeat(char, remainingWidth)
+		lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary)
+		title = titleStyle.Render(title) + " " + lines
+	}
+	return title
+}
+
+type StatusOpts struct {
+	Icon             string
+	IconColor        color.Color
+	Title            string
+	TitleColor       color.Color
+	Description      string
+	DescriptionColor color.Color
+}
+
+func Status(ops StatusOpts, width int) string {
+	t := styles.CurrentTheme()
+	icon := "●"
+	iconColor := t.Success
+	if ops.Icon != "" {
+		icon = ops.Icon
+	}
+	if ops.IconColor != nil {
+		iconColor = ops.IconColor
+	}
+	title := ops.Title
+	titleColor := t.FgMuted
+	if ops.TitleColor != nil {
+		titleColor = ops.TitleColor
+	}
+	description := ops.Description
+	descriptionColor := t.FgSubtle
+	if ops.DescriptionColor != nil {
+		descriptionColor = ops.DescriptionColor
+	}
+	icon = t.S().Base.Foreground(iconColor).Render(icon)
+	title = t.S().Base.Foreground(titleColor).Render(title)
+	if description != "" {
+		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2, "…")
+	}
+	description = t.S().Base.Foreground(descriptionColor).Render(description)
+	return strings.Join([]string{
+		icon,
+		title,
+		description,
+	}, " ")
+}
+
+type ButtonOpts struct {
+	Text           string
+	UnderlineIndex int  // Index of character to underline (0-based)
+	Selected       bool // Whether this button is selected
+}
+
+// SelectableButton creates a button with an underlined character and selection state
+func SelectableButton(opts ButtonOpts) string {
+	t := styles.CurrentTheme()
+
+	// Base style for the button
+	buttonStyle := t.S().Text
+
+	// Apply selection styling
+	if opts.Selected {
+		buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary)
+	} else {
+		buttonStyle = buttonStyle.Background(t.BgSubtle)
+	}
+
+	// Create the button text with underlined character
+	text := opts.Text
+	if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) {
+		before := text[:opts.UnderlineIndex]
+		underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1]
+		after := text[opts.UnderlineIndex+1:]
+
+		message := buttonStyle.Render(before) +
+			buttonStyle.Underline(true).Render(underlined) +
+			buttonStyle.Render(after)
+
+		return buttonStyle.Padding(0, 2).Render(message)
+	}
+
+	// Fallback if no underline index specified
+	return buttonStyle.Padding(0, 2).Render(text)
+}
+
+// SelectableButtons creates a horizontal row of selectable buttons
+func SelectableButtons(buttons []ButtonOpts, spacing string) string {
+	if spacing == "" {
+		spacing = "  "
+	}
+
+	var parts []string
+	for i, button := range buttons {
+		parts = append(parts, SelectableButton(button))
+		if i < len(buttons)-1 {
+			parts = append(parts, spacing)
+		}
+	}
+
+	return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
+}

internal/tui/components/core/list/keys.go 🔗

@@ -0,0 +1,73 @@
+package list
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Down,
+	Up,
+	NDown,
+	NUp,
+	DownOneItem,
+	UpOneItem,
+	HalfPageDown,
+	HalfPageUp,
+	Home,
+	End key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Down: key.NewBinding(
+			key.WithKeys("down", "ctrl+j", "ctrl+n"),
+		),
+		Up: key.NewBinding(
+			key.WithKeys("up", "ctrl+k", "ctrl+p"),
+		),
+		NDown: key.NewBinding(
+			key.WithKeys("j"),
+		),
+		NUp: key.NewBinding(
+			key.WithKeys("k"),
+		),
+		UpOneItem: key.NewBinding(
+			key.WithKeys("shift+up", "shift+k"),
+		),
+		DownOneItem: key.NewBinding(
+			key.WithKeys("shift+down", "shift+j"),
+		),
+		HalfPageDown: key.NewBinding(
+			key.WithKeys("d"),
+		),
+		HalfPageUp: key.NewBinding(
+			key.WithKeys("u"),
+		),
+		Home: key.NewBinding(
+			key.WithKeys("g", "home"),
+		),
+		End: key.NewBinding(
+			key.WithKeys("shift+g", "end"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Up,
+		k.Down,
+	}
+}

internal/tui/components/core/list/list.go 🔗

@@ -0,0 +1,1360 @@
+package list
+
+import (
+	"slices"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/spinner"
+	"github.com/charmbracelet/bubbles/v2/textinput"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/anim"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/sahilm/fuzzy"
+)
+
+// Constants for special index values and defaults
+const (
+	NoSelection    = -1 // Indicates no item is currently selected
+	NotRendered    = -1 // Indicates an item hasn't been rendered yet
+	NoFinalHeight  = -1 // Indicates final height hasn't been calculated
+	DefaultGapSize = 0  // Default spacing between list items
+)
+
+// ListModel defines the interface for a scrollable, selectable list component.
+// It combines the basic Model interface with sizing capabilities and list-specific operations.
+type ListModel interface {
+	util.Model
+	layout.Sizeable
+	layout.Focusable
+	SetItems([]util.Model) tea.Cmd  // Replace all items in the list
+	AppendItem(util.Model) tea.Cmd  // Add an item to the end of the list
+	PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
+	DeleteItem(int)                 // Remove an item at the specified index
+	UpdateItem(int, util.Model)     // Replace an item at the specified index
+	ResetView()                     // Clear rendering cache and reset scroll position
+	Items() []util.Model            // Get all items in the list
+	SelectedIndex() int             // Get the index of the currently selected item
+	SetSelected(int) tea.Cmd        // Set the selected item by index and scroll to it
+	Filter(string) tea.Cmd          // Filter items based on a search term
+}
+
+// HasAnim interface identifies items that support animation.
+// Items implementing this interface will receive animation update messages.
+type HasAnim interface {
+	util.Model
+	Spinning() bool // Returns true if the item is currently animating
+}
+
+// HasFilterValue interface allows items to provide a filter value for searching.
+type HasFilterValue interface {
+	FilterValue() string // Returns a string value used for filtering/searching
+}
+
+// HasMatchIndexes interface allows items to set matched character indexes.
+type HasMatchIndexes interface {
+	MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
+}
+
+// SectionHeader interface identifies items that are section headers.
+// Section headers are rendered differently and are skipped during navigation.
+type SectionHeader interface {
+	util.Model
+	IsSectionHeader() bool // Returns true if this item is a section header
+}
+
+// renderedItem represents a cached rendered item with its position and content.
+type renderedItem struct {
+	lines  []string // The rendered lines of text for this item
+	start  int      // Starting line position in the overall rendered content
+	height int      // Number of lines this item occupies
+}
+
+// renderState manages the rendering cache and state for the list.
+// It tracks which items have been rendered and their positions.
+type renderState struct {
+	items         map[int]renderedItem // Cache of rendered items by index
+	lines         []string             // All rendered lines concatenated
+	lastIndex     int                  // Index of the last rendered item
+	finalHeight   int                  // Total height when all items are rendered
+	needsRerender bool                 // Flag indicating if re-rendering is needed
+}
+
+// newRenderState creates a new render state with default values.
+func newRenderState() *renderState {
+	return &renderState{
+		items:         make(map[int]renderedItem),
+		lines:         []string{},
+		lastIndex:     NotRendered,
+		finalHeight:   NoFinalHeight,
+		needsRerender: true,
+	}
+}
+
+// reset clears all cached rendering data and resets state to initial values.
+func (rs *renderState) reset() {
+	rs.items = make(map[int]renderedItem)
+	rs.lines = []string{}
+	rs.lastIndex = NotRendered
+	rs.finalHeight = NoFinalHeight
+	rs.needsRerender = true
+}
+
+// viewState manages the visual display properties of the list.
+type viewState struct {
+	width, height int    // Dimensions of the list viewport
+	offset        int    // Current scroll offset in lines
+	reverse       bool   // Whether to render in reverse order (bottom-up)
+	content       string // The final rendered content to display
+}
+
+// selectionState manages which item is currently selected.
+type selectionState struct {
+	selectedIndex int // Index of the currently selected item, or NoSelection
+}
+
+// isValidIndex checks if the selected index is within the valid range of items.
+func (ss *selectionState) isValidIndex(itemCount int) bool {
+	return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount
+}
+
+// model is the main implementation of the ListModel interface.
+// It coordinates between view state, render state, and selection state.
+type model struct {
+	viewState      viewState      // Display and scrolling state
+	renderState    *renderState   // Rendering cache and state
+	selectionState selectionState // Item selection state
+	help           help.Model     // Help system for keyboard shortcuts
+	keyMap         KeyMap         // Key bindings for navigation
+	allItems       []util.Model   // The actual list items
+	gapSize        int            // Number of empty lines between items
+	padding        []int          // Padding around the list content
+	wrapNavigation bool           // Whether to wrap navigation at the ends
+
+	filterable        bool            // Whether items can be filtered
+	filterPlaceholder string          // Placeholder text for filter input
+	filteredItems     []util.Model    // Filtered items based on current search
+	input             textinput.Model // Input field for filtering items
+	inputStyle        lipgloss.Style  // Style for the input field
+	hideFilterInput   bool            // Whether to hide the filter input field
+	currentSearch     string          // Current search term for filtering
+
+	isFocused bool // Whether the list is currently focused
+}
+
+// listOptions is a function type for configuring list options.
+type listOptions func(*model)
+
+// WithKeyMap sets custom key bindings for the list.
+func WithKeyMap(k KeyMap) listOptions {
+	return func(m *model) {
+		m.keyMap = k
+	}
+}
+
+// WithReverse sets whether the list should render in reverse order (newest items at bottom).
+func WithReverse(reverse bool) listOptions {
+	return func(m *model) {
+		m.setReverse(reverse)
+	}
+}
+
+// WithGapSize sets the number of empty lines to insert between list items.
+func WithGapSize(gapSize int) listOptions {
+	return func(m *model) {
+		m.gapSize = gapSize
+	}
+}
+
+// WithPadding sets the padding around the list content.
+// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal,
+// 4 values = top/right/bottom/left.
+func WithPadding(padding ...int) listOptions {
+	return func(m *model) {
+		m.padding = padding
+	}
+}
+
+// WithItems sets the initial items for the list.
+func WithItems(items []util.Model) listOptions {
+	return func(m *model) {
+		m.allItems = items
+		m.filteredItems = items // Initially, all items are visible
+	}
+}
+
+// WithFilterable enables filtering of items based on their FilterValue.
+func WithFilterable(filterable bool) listOptions {
+	return func(m *model) {
+		m.filterable = filterable
+	}
+}
+
+// WithHideFilterInput hides the filter input field.
+func WithHideFilterInput(hide bool) listOptions {
+	return func(m *model) {
+		m.hideFilterInput = hide
+	}
+}
+
+// WithFilterPlaceholder sets the placeholder text for the filter input field.
+func WithFilterPlaceholder(placeholder string) listOptions {
+	return func(m *model) {
+		m.filterPlaceholder = placeholder
+	}
+}
+
+// WithInputStyle sets the style for the filter input field.
+func WithInputStyle(style lipgloss.Style) listOptions {
+	return func(m *model) {
+		m.inputStyle = style
+	}
+}
+
+// WithWrapNavigation enables wrapping navigation at the ends of the list.
+func WithWrapNavigation(wrap bool) listOptions {
+	return func(m *model) {
+		m.wrapNavigation = wrap
+	}
+}
+
+// New creates a new list model with the specified options.
+// The list starts with no items selected and requires SetItems to be called
+// or items to be provided via WithItems option.
+func New(opts ...listOptions) ListModel {
+	t := styles.CurrentTheme()
+
+	m := &model{
+		help:              help.New(),
+		keyMap:            DefaultKeyMap(),
+		allItems:          []util.Model{},
+		filteredItems:     []util.Model{},
+		renderState:       newRenderState(),
+		gapSize:           DefaultGapSize,
+		padding:           []int{},
+		selectionState:    selectionState{selectedIndex: NoSelection},
+		filterPlaceholder: "Type to filter...",
+		inputStyle:        t.S().Base.Padding(0, 1, 1, 1),
+		isFocused:         true,
+	}
+	for _, opt := range opts {
+		opt(m)
+	}
+
+	if m.filterable && !m.hideFilterInput {
+		t := styles.CurrentTheme()
+		ti := textinput.New()
+		ti.Placeholder = m.filterPlaceholder
+		ti.SetVirtualCursor(false)
+		ti.Focus()
+		ti.SetStyles(t.S().TextInput)
+		m.input = ti
+
+		// disable j,k movements
+		m.keyMap.NDown.SetEnabled(false)
+		m.keyMap.NUp.SetEnabled(false)
+	}
+	return m
+}
+
+// Init initializes the list component and sets up the initial items.
+// This is called automatically by the Bubble Tea framework.
+func (m *model) Init() tea.Cmd {
+	return m.SetItems(m.filteredItems)
+}
+
+// Update handles incoming messages and updates the list state accordingly.
+// It processes keyboard input, animation messages, and forwards other messages
+// to the currently selected item.
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		return m.handleKeyPress(msg)
+	case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
+		return m.handleAnimationMsg(msg)
+	}
+	if m.selectionState.isValidIndex(len(m.filteredItems)) {
+		return m.updateSelectedItem(msg)
+	}
+
+	return m, nil
+}
+
+// View renders the list to a string for display.
+// Returns empty string if the list has no dimensions.
+// Triggers re-rendering if needed before returning content.
+func (m *model) View() tea.View {
+	if m.viewState.height == 0 || m.viewState.width == 0 {
+		return tea.NewView("") // No content to display
+	}
+	if m.renderState.needsRerender {
+		m.renderVisible()
+	}
+
+	content := lipgloss.NewStyle().
+		Padding(m.padding...).
+		Height(m.viewState.height).
+		Render(m.viewState.content)
+
+	if m.filterable && !m.hideFilterInput {
+		content = lipgloss.JoinVertical(
+			lipgloss.Left,
+			m.inputStyle.Render(m.input.View()),
+			content,
+		)
+	}
+	view := tea.NewView(content)
+	if m.filterable && !m.hideFilterInput {
+		view.SetCursor(m.input.Cursor())
+	}
+	return view
+}
+
+// handleKeyPress processes keyboard input for list navigation.
+// Supports scrolling, item selection, and navigation to top/bottom.
+func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	switch {
+	case key.Matches(msg, m.keyMap.Down) || key.Matches(msg, m.keyMap.NDown):
+		m.scrollDown(1)
+	case key.Matches(msg, m.keyMap.Up) || key.Matches(msg, m.keyMap.NUp):
+		m.scrollUp(1)
+	case key.Matches(msg, m.keyMap.DownOneItem):
+		return m, m.selectNextItem()
+	case key.Matches(msg, m.keyMap.UpOneItem):
+		return m, m.selectPreviousItem()
+	case key.Matches(msg, m.keyMap.HalfPageDown):
+		m.scrollDown(m.listHeight() / 2)
+	case key.Matches(msg, m.keyMap.HalfPageUp):
+		m.scrollUp(m.listHeight() / 2)
+	case key.Matches(msg, m.keyMap.Home):
+		return m, m.goToTop()
+	case key.Matches(msg, m.keyMap.End):
+		return m, m.goToBottom()
+	default:
+		if !m.filterable || m.hideFilterInput {
+			return m, nil // Ignore other keys if not filterable or input is hidden
+		}
+		var cmds []tea.Cmd
+		u, cmd := m.input.Update(msg)
+		m.input = u
+		cmds = append(cmds, cmd)
+		if m.currentSearch != m.input.Value() {
+			cmd = m.Filter(m.input.Value())
+			cmds = append(cmds, cmd)
+		}
+		m.currentSearch = m.input.Value()
+		return m, tea.Batch(cmds...)
+	}
+	return m, nil
+}
+
+// handleAnimationMsg forwards animation messages to items that support animation.
+// Only items implementing HasAnim and currently spinning receive these messages.
+func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	for inx, item := range m.filteredItems {
+		if i, ok := item.(HasAnim); ok && i.Spinning() {
+			updated, cmd := i.Update(msg)
+			cmds = append(cmds, cmd)
+			if u, ok := updated.(util.Model); ok {
+				m.UpdateItem(inx, u)
+			}
+		}
+	}
+	return m, tea.Batch(cmds...)
+}
+
+// updateSelectedItem forwards messages to the currently selected item.
+// This allows the selected item to handle its own input and state changes.
+func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg)
+	cmds = append(cmds, cmd)
+	if updated, ok := u.(util.Model); ok {
+		m.UpdateItem(m.selectionState.selectedIndex, updated)
+	}
+	return m, tea.Batch(cmds...)
+}
+
+// scrollDown scrolls the list down by the specified amount.
+// Direction is automatically adjusted based on reverse mode.
+func (m *model) scrollDown(amount int) {
+	if m.viewState.reverse {
+		m.decreaseOffset(amount)
+	} else {
+		m.increaseOffset(amount)
+	}
+}
+
+// scrollUp scrolls the list up by the specified amount.
+// Direction is automatically adjusted based on reverse mode.
+func (m *model) scrollUp(amount int) {
+	if m.viewState.reverse {
+		m.increaseOffset(amount)
+	} else {
+		m.decreaseOffset(amount)
+	}
+}
+
+// Items returns a copy of all items in the list.
+func (m *model) Items() []util.Model {
+	return m.filteredItems
+}
+
+// renderVisible determines which rendering strategy to use and triggers rendering.
+// Uses forward rendering for normal mode and reverse rendering for reverse mode.
+func (m *model) renderVisible() {
+	if m.viewState.reverse {
+		m.renderVisibleReverse()
+	} else {
+		m.renderVisibleForward()
+	}
+}
+
+// renderVisibleForward renders items from top to bottom (normal mode).
+// Only renders items that are currently visible or near the viewport.
+func (m *model) renderVisibleForward() {
+	renderer := &forwardRenderer{
+		model:   m,
+		start:   0,
+		cutoff:  m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd
+		items:   m.filteredItems,
+		realIdx: m.renderState.lastIndex,
+	}
+
+	if m.renderState.lastIndex > NotRendered {
+		renderer.items = m.filteredItems[m.renderState.lastIndex+1:]
+		renderer.start = len(m.renderState.lines)
+	}
+
+	renderer.render()
+	m.finalizeRender()
+}
+
+// renderVisibleReverse renders items from bottom to top (reverse mode).
+// Used when new items should appear at the bottom (like chat messages).
+func (m *model) renderVisibleReverse() {
+	renderer := &reverseRenderer{
+		model:   m,
+		start:   0,
+		cutoff:  m.viewState.offset + m.listHeight() + m.listHeight()/2,
+		items:   m.filteredItems,
+		realIdx: m.renderState.lastIndex,
+	}
+
+	if m.renderState.lastIndex > NotRendered {
+		renderer.items = m.filteredItems[:m.renderState.lastIndex]
+		renderer.start = len(m.renderState.lines)
+	} else {
+		m.renderState.lastIndex = len(m.filteredItems)
+		renderer.realIdx = len(m.filteredItems)
+	}
+
+	renderer.render()
+	m.finalizeRender()
+}
+
+// finalizeRender completes the rendering process by updating scroll bounds and content.
+func (m *model) finalizeRender() {
+	m.renderState.needsRerender = false
+	if m.renderState.finalHeight > NoFinalHeight {
+		m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
+	}
+	m.updateContent()
+}
+
+// updateContent extracts the visible portion of rendered content for display.
+// Handles both normal and reverse rendering modes.
+func (m *model) updateContent() {
+	maxHeight := min(m.listHeight(), len(m.renderState.lines))
+	if m.viewState.offset >= len(m.renderState.lines) {
+		m.viewState.content = ""
+		return
+	}
+
+	if m.viewState.reverse {
+		end := len(m.renderState.lines) - m.viewState.offset
+		start := max(0, end-maxHeight)
+		m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
+	} else {
+		endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines))
+		m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n")
+	}
+}
+
+// forwardRenderer handles rendering items from top to bottom.
+// It builds up the rendered content incrementally, caching results for performance.
+type forwardRenderer struct {
+	model   *model       // Reference to the parent list model
+	start   int          // Current line position in the overall content
+	cutoff  int          // Line position where we can stop rendering
+	items   []util.Model // Items to render (may be a subset)
+	realIdx int          // Real index in the full item list
+}
+
+// render processes items in forward order, building up the rendered content.
+func (r *forwardRenderer) render() {
+	for _, item := range r.items {
+		r.realIdx++
+		if r.start > r.cutoff {
+			break
+		}
+
+		itemLines := r.getOrRenderItem(item)
+		if r.realIdx == len(r.model.filteredItems)-1 {
+			r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
+		}
+
+		r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
+		r.model.renderState.lastIndex = r.realIdx
+		r.start += len(itemLines)
+	}
+}
+
+// getOrRenderItem retrieves cached content or renders the item if not cached.
+func (r *forwardRenderer) getOrRenderItem(item util.Model) []string {
+	if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
+		return cachedContent.lines
+	}
+
+	itemLines := r.renderItemLines(item)
+	r.model.renderState.items[r.realIdx] = renderedItem{
+		lines:  itemLines,
+		start:  r.start,
+		height: len(itemLines),
+	}
+	return itemLines
+}
+
+// renderItemLines converts an item to its string representation with gaps.
+func (r *forwardRenderer) renderItemLines(item util.Model) []string {
+	return r.model.getItemLines(item)
+}
+
+// reverseRenderer handles rendering items from bottom to top.
+// Used in reverse mode where new items appear at the bottom.
+type reverseRenderer struct {
+	model   *model       // Reference to the parent list model
+	start   int          // Current line position in the overall content
+	cutoff  int          // Line position where we can stop rendering
+	items   []util.Model // Items to render (may be a subset)
+	realIdx int          // Real index in the full item list
+}
+
+// render processes items in reverse order, prepending to the rendered content.
+func (r *reverseRenderer) render() {
+	for i := len(r.items) - 1; i >= 0; i-- {
+		r.realIdx--
+		if r.start > r.cutoff {
+			break
+		}
+
+		itemLines := r.getOrRenderItem(r.items[i])
+		if r.realIdx == 0 {
+			r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
+		}
+
+		r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
+		r.model.renderState.lastIndex = r.realIdx
+		r.start += len(itemLines)
+	}
+}
+
+// getOrRenderItem retrieves cached content or renders the item if not cached.
+func (r *reverseRenderer) getOrRenderItem(item util.Model) []string {
+	if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
+		return cachedContent.lines
+	}
+
+	itemLines := r.renderItemLines(item)
+	r.model.renderState.items[r.realIdx] = renderedItem{
+		lines:  itemLines,
+		start:  r.start,
+		height: len(itemLines),
+	}
+	return itemLines
+}
+
+// renderItemLines converts an item to its string representation with gaps.
+func (r *reverseRenderer) renderItemLines(item util.Model) []string {
+	return r.model.getItemLines(item)
+}
+
+// selectPreviousItem moves selection to the previous item in the list.
+// Handles focus management and ensures the selected item remains visible.
+// Skips section headers during navigation.
+func (m *model) selectPreviousItem() tea.Cmd {
+	if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation {
+		// If at the beginning and wrapping is enabled, go to the last item
+		return m.goToBottom()
+	}
+	if m.selectionState.selectedIndex <= 0 {
+		return nil
+	}
+
+	cmds := []tea.Cmd{m.blurSelected()}
+	m.selectionState.selectedIndex--
+
+	// Skip section headers
+	for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) {
+		m.selectionState.selectedIndex--
+	}
+
+	// If we went past the beginning, stay at the first non-header item
+	if m.selectionState.selectedIndex <= 0 {
+		cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed
+		return tea.Batch(cmds...)
+	}
+
+	cmds = append(cmds, m.focusSelected())
+	m.ensureSelectedItemVisible()
+	return tea.Batch(cmds...)
+}
+
+// selectNextItem moves selection to the next item in the list.
+// Handles focus management and ensures the selected item remains visible.
+// Skips section headers during navigation.
+func (m *model) selectNextItem() tea.Cmd {
+	if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation {
+		// If at the end and wrapping is enabled, go to the first item
+		return m.goToTop()
+	}
+	if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
+		return nil
+	}
+
+	cmds := []tea.Cmd{m.blurSelected()}
+	m.selectionState.selectedIndex++
+
+	// Skip section headers
+	for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) {
+		m.selectionState.selectedIndex++
+	}
+
+	// If we went past the end, stay at the last non-header item
+	if m.selectionState.selectedIndex >= len(m.filteredItems) {
+		m.selectionState.selectedIndex = m.findLastSelectableItem()
+	}
+
+	cmds = append(cmds, m.focusSelected())
+	m.ensureSelectedItemVisible()
+	return tea.Batch(cmds...)
+}
+
+// isSectionHeader checks if the item at the given index is a section header.
+func (m *model) isSectionHeader(index int) bool {
+	if index < 0 || index >= len(m.filteredItems) {
+		return false
+	}
+	if header, ok := m.filteredItems[index].(SectionHeader); ok {
+		return header.IsSectionHeader()
+	}
+	return false
+}
+
+// findFirstSelectableItem finds the first item that is not a section header.
+func (m *model) findFirstSelectableItem() int {
+	for i := range m.filteredItems {
+		if !m.isSectionHeader(i) {
+			return i
+		}
+	}
+	return NoSelection
+}
+
+// findLastSelectableItem finds the last item that is not a section header.
+func (m *model) findLastSelectableItem() int {
+	for i := len(m.filteredItems) - 1; i >= 0; i-- {
+		if !m.isSectionHeader(i) {
+			return i
+		}
+	}
+	return NoSelection
+}
+
+// ensureSelectedItemVisible scrolls the list to make the selected item visible.
+// Uses different strategies for forward and reverse rendering modes.
+func (m *model) ensureSelectedItemVisible() {
+	cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex]
+	if !ok {
+		m.renderState.needsRerender = true
+		return
+	}
+
+	if m.viewState.reverse {
+		m.ensureVisibleReverse(cachedItem)
+	} else {
+		m.ensureVisibleForward(cachedItem)
+	}
+	m.renderState.needsRerender = true
+}
+
+// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
+// Handles both large items (taller than viewport) and normal items.
+func (m *model) ensureVisibleForward(cachedItem renderedItem) {
+	if cachedItem.height >= m.listHeight() {
+		if m.selectionState.selectedIndex > 0 {
+			changeNeeded := m.viewState.offset - cachedItem.start
+			m.decreaseOffset(changeNeeded)
+		} else {
+			changeNeeded := cachedItem.start - m.viewState.offset
+			m.increaseOffset(changeNeeded)
+		}
+		return
+	}
+
+	if cachedItem.start < m.viewState.offset {
+		changeNeeded := m.viewState.offset - cachedItem.start
+		m.decreaseOffset(changeNeeded)
+	} else {
+		end := cachedItem.start + cachedItem.height
+		if end > m.viewState.offset+m.listHeight() {
+			changeNeeded := end - (m.viewState.offset + m.listHeight())
+			m.increaseOffset(changeNeeded)
+		}
+	}
+}
+
+// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
+// Handles both large items (taller than viewport) and normal items.
+func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
+	if cachedItem.height >= m.listHeight() {
+		if m.selectionState.selectedIndex < len(m.filteredItems)-1 {
+			changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
+			m.decreaseOffset(changeNeeded)
+		} else {
+			changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
+			m.increaseOffset(changeNeeded)
+		}
+		return
+	}
+
+	if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
+		changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
+		m.increaseOffset(changeNeeded)
+	} else if cachedItem.start < m.viewState.offset {
+		changeNeeded := m.viewState.offset - cachedItem.start
+		m.decreaseOffset(changeNeeded)
+	}
+}
+
+// goToBottom switches to reverse mode and selects the last selectable item.
+// Commonly used for chat-like interfaces where new content appears at the bottom.
+// Skips section headers when selecting the last item.
+func (m *model) goToBottom() tea.Cmd {
+	cmds := []tea.Cmd{m.blurSelected()}
+	m.viewState.reverse = true
+	if m.isFocused {
+		m.selectionState.selectedIndex = m.findLastSelectableItem()
+		cmds = append(cmds, m.focusSelected())
+	}
+	m.ResetView()
+	return tea.Batch(cmds...)
+}
+
+// goToTop switches to forward mode and selects the first selectable item.
+// Standard behavior for most list interfaces.
+// Skips section headers when selecting the first item.
+func (m *model) goToTop() tea.Cmd {
+	cmds := []tea.Cmd{m.blurSelected()}
+	m.viewState.reverse = false
+	m.selectionState.selectedIndex = m.findFirstSelectableItem()
+	cmds = append(cmds, m.focusSelected())
+	m.ResetView()
+	return tea.Batch(cmds...)
+}
+
+// ResetView clears all cached rendering data and resets scroll position.
+// Forces a complete re-render on the next View() call.
+func (m *model) ResetView() {
+	m.renderState.reset()
+	m.viewState.offset = 0
+}
+
+// focusSelected gives focus to the currently selected item if it supports focus.
+// Triggers a re-render of the item to show its focused state.
+func (m *model) focusSelected() tea.Cmd {
+	if !m.isFocused {
+		return nil // No focus change if the list is not focused
+	}
+	if !m.selectionState.isValidIndex(len(m.filteredItems)) {
+		return nil
+	}
+	if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
+		cmd := i.Focus()
+		m.rerenderItem(m.selectionState.selectedIndex)
+		return cmd
+	}
+	return nil
+}
+
+// blurSelected removes focus from the currently selected item if it supports focus.
+// Triggers a re-render of the item to show its unfocused state.
+func (m *model) blurSelected() tea.Cmd {
+	if !m.selectionState.isValidIndex(len(m.filteredItems)) {
+		return nil
+	}
+	if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
+		cmd := i.Blur()
+		m.rerenderItem(m.selectionState.selectedIndex)
+		return cmd
+	}
+	return nil
+}
+
+// rerenderItem updates the cached rendering of a specific item.
+// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
+// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
+func (m *model) rerenderItem(inx int) {
+	if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 {
+		return
+	}
+
+	cachedItem, ok := m.renderState.items[inx]
+	if !ok {
+		return
+	}
+
+	rerenderedLines := m.getItemLines(m.filteredItems[inx])
+	if slices.Equal(cachedItem.lines, rerenderedLines) {
+		return
+	}
+
+	m.updateRenderedLines(cachedItem, rerenderedLines)
+	m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
+	m.updateCachedItem(inx, cachedItem, rerenderedLines)
+	m.renderState.needsRerender = true
+}
+
+// getItemLines converts an item to its rendered lines, including any gap spacing.
+// Handles section headers with special styling.
+func (m *model) getItemLines(item util.Model) []string {
+	var itemLines []string
+
+	itemLines = strings.Split(item.View().String(), "\n")
+
+	if m.gapSize > 0 {
+		gap := make([]string, m.gapSize)
+		itemLines = append(itemLines, gap...)
+	}
+	return itemLines
+}
+
+// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
+func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
+	start, end := m.getItemBounds(cachedItem)
+	totalLines := len(m.renderState.lines)
+
+	if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines {
+		m.renderState.lines = slices.Delete(m.renderState.lines, start, end)
+		m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...)
+	}
+}
+
+// getItemBounds calculates the start and end line positions for an item.
+// Handles both forward and reverse rendering modes.
+func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) {
+	start = cachedItem.start
+	end = start + cachedItem.height
+
+	if m.viewState.reverse {
+		totalLines := len(m.renderState.lines)
+		end = totalLines - cachedItem.start
+		start = end - cachedItem.height
+	}
+	return start, end
+}
+
+// updateItemPositions recalculates positions for items after the changed item.
+// This is necessary when an item's height changes, affecting subsequent items.
+func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) {
+	if cachedItem.height == newHeight {
+		return
+	}
+
+	if inx == len(m.filteredItems)-1 {
+		m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
+	}
+
+	currentStart := cachedItem.start + newHeight
+	if m.viewState.reverse {
+		m.updatePositionsReverse(inx, currentStart)
+	} else {
+		m.updatePositionsForward(inx, currentStart)
+	}
+}
+
+// updatePositionsForward updates positions for items after the changed item in forward mode.
+func (m *model) updatePositionsForward(inx int, currentStart int) {
+	for i := inx + 1; i < len(m.filteredItems); i++ {
+		if existing, ok := m.renderState.items[i]; ok {
+			existing.start = currentStart
+			currentStart += existing.height
+			m.renderState.items[i] = existing
+		} else {
+			break
+		}
+	}
+}
+
+// updatePositionsReverse updates positions for items before the changed item in reverse mode.
+func (m *model) updatePositionsReverse(inx int, currentStart int) {
+	for i := inx - 1; i >= 0; i-- {
+		if existing, ok := m.renderState.items[i]; ok {
+			existing.start = currentStart
+			currentStart += existing.height
+			m.renderState.items[i] = existing
+		} else {
+			break
+		}
+	}
+}
+
+// updateCachedItem updates the cached rendering information for a specific item.
+func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
+	m.renderState.items[inx] = renderedItem{
+		lines:  newLines,
+		start:  cachedItem.start,
+		height: len(newLines),
+	}
+}
+
+// increaseOffset scrolls the list down by increasing the offset.
+// Respects the final height limit to prevent scrolling past the end.
+func (m *model) increaseOffset(n int) {
+	if m.renderState.finalHeight > NoFinalHeight {
+		if m.viewState.offset < m.renderState.finalHeight {
+			m.viewState.offset += n
+			if m.viewState.offset > m.renderState.finalHeight {
+				m.viewState.offset = m.renderState.finalHeight
+			}
+			m.renderState.needsRerender = true
+		}
+	} else {
+		m.viewState.offset += n
+		m.renderState.needsRerender = true
+	}
+}
+
+// decreaseOffset scrolls the list up by decreasing the offset.
+// Prevents scrolling above the beginning of the list.
+func (m *model) decreaseOffset(n int) {
+	if m.viewState.offset > 0 {
+		m.viewState.offset -= n
+		if m.viewState.offset < 0 {
+			m.viewState.offset = 0
+		}
+		m.renderState.needsRerender = true
+	}
+}
+
+// UpdateItem replaces an item at the specified index with a new item.
+// Handles focus management and triggers re-rendering as needed.
+func (m *model) UpdateItem(inx int, item util.Model) {
+	if inx < 0 || inx >= len(m.filteredItems) {
+		return
+	}
+	m.filteredItems[inx] = item
+	if m.selectionState.selectedIndex == inx {
+		m.focusSelected()
+	}
+	m.setItemSize(inx)
+	m.rerenderItem(inx)
+	m.renderState.needsRerender = true
+}
+
+// GetSize returns the current dimensions of the list.
+func (m *model) GetSize() (int, int) {
+	return m.viewState.width, m.viewState.height
+}
+
+// SetSize updates the list dimensions and triggers a complete re-render.
+// Also updates the size of all items that support sizing.
+func (m *model) SetSize(width int, height int) tea.Cmd {
+	if m.filterable && !m.hideFilterInput {
+		height -= 2 // adjust for input field height and border
+	}
+
+	if m.viewState.width == width && m.viewState.height == height {
+		return nil
+	}
+	if m.viewState.height != height {
+		m.renderState.finalHeight = NoFinalHeight
+		m.viewState.height = height
+	}
+	m.viewState.width = width
+	m.ResetView()
+	if m.filterable && !m.hideFilterInput {
+		m.input.SetWidth(m.getItemWidth() - 5)
+	}
+	return m.setAllItemsSize()
+}
+
+// getItemWidth calculates the available width for items, accounting for padding.
+func (m *model) getItemWidth() int {
+	width := m.viewState.width
+	switch len(m.padding) {
+	case 1:
+		width -= m.padding[0] * 2
+	case 2, 3:
+		width -= m.padding[1] * 2
+	case 4:
+		width -= m.padding[1] + m.padding[3]
+	}
+	return max(0, width)
+}
+
+// setItemSize updates the size of a specific item if it supports sizing.
+func (m *model) setItemSize(inx int) tea.Cmd {
+	if inx < 0 || inx >= len(m.filteredItems) {
+		return nil
+	}
+	if i, ok := m.filteredItems[inx].(layout.Sizeable); ok {
+		return i.SetSize(m.getItemWidth(), 0)
+	}
+	return nil
+}
+
+// setAllItemsSize updates the size of all items that support sizing.
+func (m *model) setAllItemsSize() tea.Cmd {
+	var cmds []tea.Cmd
+	for i := range m.filteredItems {
+		if cmd := m.setItemSize(i); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+// listHeight calculates the available height for list content, accounting for padding.
+func (m *model) listHeight() int {
+	height := m.viewState.height
+	switch len(m.padding) {
+	case 1:
+		height -= m.padding[0] * 2
+	case 2:
+		height -= m.padding[0] * 2
+	case 3, 4:
+		height -= m.padding[0] + m.padding[2]
+	}
+	if m.filterable && !m.hideFilterInput {
+		height -= lipgloss.Height(m.inputStyle.Render("dummy"))
+	}
+	return max(0, height)
+}
+
+// AppendItem adds a new item to the end of the list.
+// Automatically switches to reverse mode and scrolls to show the new item.
+func (m *model) AppendItem(item util.Model) tea.Cmd {
+	cmds := []tea.Cmd{
+		item.Init(),
+	}
+	m.allItems = append(m.allItems, item)
+	m.filteredItems = m.allItems
+	cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1))
+	cmds = append(cmds, m.goToBottom())
+	m.renderState.needsRerender = true
+	return tea.Batch(cmds...)
+}
+
+// DeleteItem removes an item at the specified index.
+// Adjusts selection if necessary and triggers a complete re-render.
+func (m *model) DeleteItem(i int) {
+	if i < 0 || i >= len(m.filteredItems) {
+		return
+	}
+	m.allItems = slices.Delete(m.allItems, i, i+1)
+	delete(m.renderState.items, i)
+	m.filteredItems = m.allItems
+
+	if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
+		m.selectionState.selectedIndex--
+	} else if m.selectionState.selectedIndex > i {
+		m.selectionState.selectedIndex--
+	}
+
+	m.ResetView()
+	m.renderState.needsRerender = true
+}
+
+// PrependItem adds a new item to the beginning of the list.
+// Adjusts cached positions and selection index, then switches to forward mode.
+func (m *model) PrependItem(item util.Model) tea.Cmd {
+	cmds := []tea.Cmd{item.Init()}
+	m.allItems = append([]util.Model{item}, m.allItems...)
+	m.filteredItems = m.allItems
+
+	// Shift all cached item indices by 1
+	newItems := make(map[int]renderedItem, len(m.renderState.items))
+	for k, v := range m.renderState.items {
+		newItems[k+1] = v
+	}
+	m.renderState.items = newItems
+
+	if m.selectionState.selectedIndex >= 0 {
+		m.selectionState.selectedIndex++
+	}
+
+	cmds = append(cmds, m.goToTop())
+	cmds = append(cmds, m.setItemSize(0))
+	m.renderState.needsRerender = true
+	return tea.Batch(cmds...)
+}
+
+// setReverse switches between forward and reverse rendering modes.
+func (m *model) setReverse(reverse bool) {
+	if reverse {
+		m.goToBottom()
+	} else {
+		m.goToTop()
+	}
+}
+
+// SetItems replaces all items in the list with a new set.
+// Initializes all items, sets their sizes, and establishes initial selection.
+// Ensures the initial selection skips section headers.
+func (m *model) SetItems(items []util.Model) tea.Cmd {
+	m.allItems = items
+	m.filteredItems = items
+	cmds := []tea.Cmd{m.setAllItemsSize()}
+
+	for _, item := range m.filteredItems {
+		cmds = append(cmds, item.Init())
+	}
+
+	if len(m.filteredItems) > 0 {
+		if m.viewState.reverse {
+			m.selectionState.selectedIndex = m.findLastSelectableItem()
+		} else {
+			m.selectionState.selectedIndex = m.findFirstSelectableItem()
+		}
+		if cmd := m.focusSelected(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	} else {
+		m.selectionState.selectedIndex = NoSelection
+	}
+
+	m.ResetView()
+	return tea.Batch(cmds...)
+}
+
+// section represents a group of items under a section header.
+type section struct {
+	header SectionHeader
+	items  []util.Model
+}
+
+// parseSections parses the flat item list into sections.
+func (m *model) parseSections() []section {
+	var sections []section
+	var currentSection *section
+
+	for _, item := range m.allItems {
+		if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() {
+			// Start a new section
+			if currentSection != nil {
+				sections = append(sections, *currentSection)
+			}
+			currentSection = &section{
+				header: header,
+				items:  []util.Model{},
+			}
+		} else if currentSection != nil {
+			// Add item to current section
+			currentSection.items = append(currentSection.items, item)
+		} else {
+			// Item without a section header - create an implicit section
+			if len(sections) == 0 || sections[len(sections)-1].header != nil {
+				sections = append(sections, section{
+					header: nil,
+					items:  []util.Model{item},
+				})
+			} else {
+				// Add to the last implicit section
+				sections[len(sections)-1].items = append(sections[len(sections)-1].items, item)
+			}
+		}
+	}
+
+	// Don't forget the last section
+	if currentSection != nil {
+		sections = append(sections, *currentSection)
+	}
+
+	return sections
+}
+
+// flattenSections converts sections back to a flat list.
+func (m *model) flattenSections(sections []section) []util.Model {
+	var result []util.Model
+
+	for _, sect := range sections {
+		if sect.header != nil {
+			result = append(result, sect.header)
+		}
+		result = append(result, sect.items...)
+	}
+
+	return result
+}
+
+func (m *model) Filter(search string) tea.Cmd {
+	var cmds []tea.Cmd
+	search = strings.TrimSpace(search)
+	search = strings.ToLower(search)
+
+	// Clear focus and match indexes from all items
+	for _, item := range m.allItems {
+		if i, ok := item.(layout.Focusable); ok {
+			cmds = append(cmds, i.Blur())
+		}
+		if i, ok := item.(HasMatchIndexes); ok {
+			i.MatchIndexes(make([]int, 0))
+		}
+	}
+
+	if search == "" {
+		cmds = append(cmds, m.SetItems(m.allItems))
+		return tea.Batch(cmds...)
+	}
+
+	// Parse items into sections
+	sections := m.parseSections()
+	var filteredSections []section
+
+	for _, sect := range sections {
+		filteredSection := m.filterSection(sect, search)
+		if filteredSection != nil {
+			filteredSections = append(filteredSections, *filteredSection)
+		}
+	}
+
+	// Rebuild flat list from filtered sections
+	m.filteredItems = m.flattenSections(filteredSections)
+
+	// Set initial selection
+	if len(m.filteredItems) > 0 {
+		if m.viewState.reverse {
+			slices.Reverse(m.filteredItems)
+			m.selectionState.selectedIndex = m.findLastSelectableItem()
+		} else {
+			m.selectionState.selectedIndex = m.findFirstSelectableItem()
+		}
+		if cmd := m.focusSelected(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	} else {
+		m.selectionState.selectedIndex = NoSelection
+	}
+
+	m.ResetView()
+	return tea.Batch(cmds...)
+}
+
+// filterSection filters items within a section and returns the section if it has matches.
+func (m *model) filterSection(sect section, search string) *section {
+	var matchedItems []util.Model
+	var hasHeaderMatch bool
+
+	// Check if section header itself matches
+	if sect.header != nil {
+		headerText := strings.ToLower(sect.header.View().String())
+		if strings.Contains(headerText, search) {
+			hasHeaderMatch = true
+			// If header matches, include all items in the section
+			matchedItems = sect.items
+		}
+	}
+
+	// If header didn't match, filter items within the section
+	if !hasHeaderMatch && len(sect.items) > 0 {
+		// Create words array for items in this section
+		words := make([]string, len(sect.items))
+		for i, item := range sect.items {
+			if f, ok := item.(HasFilterValue); ok {
+				words[i] = strings.ToLower(f.FilterValue())
+			} else {
+				words[i] = ""
+			}
+		}
+
+		// Find matches within this section
+		matches := fuzzy.Find(search, words)
+
+		// Sort matches by score but preserve relative order for equal scores
+		sort.SliceStable(matches, func(i, j int) bool {
+			return matches[i].Score > matches[j].Score
+		})
+
+		// Build matched items list
+		for _, match := range matches {
+			item := sect.items[match.Index]
+			if i, ok := item.(HasMatchIndexes); ok {
+				i.MatchIndexes(match.MatchedIndexes)
+			}
+			matchedItems = append(matchedItems, item)
+		}
+	}
+
+	// Return section only if it has matches
+	if len(matchedItems) > 0 {
+		return &section{
+			header: sect.header,
+			items:  matchedItems,
+		}
+	}
+
+	return nil
+}
+
+// SelectedIndex returns the index of the currently selected item.
+func (m *model) SelectedIndex() int {
+	if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) {
+		return NoSelection
+	}
+	return m.selectionState.selectedIndex
+}
+
+// SetSelected sets the selected item by index and automatically scrolls to make it visible.
+// If the index is invalid or points to a section header, it finds the nearest selectable item.
+func (m *model) SetSelected(index int) tea.Cmd {
+	changeNeeded := m.selectionState.selectedIndex - index
+	cmds := []tea.Cmd{}
+	if changeNeeded < 0 {
+		for range -changeNeeded {
+			cmds = append(cmds, m.selectNextItem())
+			m.renderVisible()
+		}
+	} else if changeNeeded > 0 {
+		for range changeNeeded {
+			cmds = append(cmds, m.selectPreviousItem())
+			m.renderVisible()
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+// Blur implements ListModel.
+func (m *model) Blur() tea.Cmd {
+	m.isFocused = false
+	cmd := m.blurSelected()
+	return cmd
+}
+
+// Focus implements ListModel.
+func (m *model) Focus() tea.Cmd {
+	m.isFocused = true
+	cmd := m.focusSelected()
+	return cmd
+}
+
+// IsFocused implements ListModel.
+func (m *model) IsFocused() bool {
+	return m.isFocused
+}

internal/tui/components/core/status.go 🔗

@@ -1,293 +0,0 @@
-package core
-
-import (
-	"fmt"
-	"strings"
-	"time"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/lsp"
-	"github.com/opencode-ai/opencode/internal/lsp/protocol"
-	"github.com/opencode-ai/opencode/internal/pubsub"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/components/chat"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type StatusCmp interface {
-	tea.Model
-}
-
-type statusCmp struct {
-	info       util.InfoMsg
-	width      int
-	messageTTL time.Duration
-	lspClients map[string]*lsp.Client
-	session    session.Session
-}
-
-// clearMessageCmd is a command that clears status messages after a timeout
-func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
-	return tea.Tick(ttl, func(time.Time) tea.Msg {
-		return util.ClearStatusMsg{}
-	})
-}
-
-func (m statusCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		return m, nil
-	case chat.SessionSelectedMsg:
-		m.session = msg
-	case chat.SessionClearedMsg:
-		m.session = session.Session{}
-	case pubsub.Event[session.Session]:
-		if msg.Type == pubsub.UpdatedEvent {
-			if m.session.ID == msg.Payload.ID {
-				m.session = msg.Payload
-			}
-		}
-	case util.InfoMsg:
-		m.info = msg
-		ttl := msg.TTL
-		if ttl == 0 {
-			ttl = m.messageTTL
-		}
-		return m, m.clearMessageCmd(ttl)
-	case util.ClearStatusMsg:
-		m.info = util.InfoMsg{}
-	}
-	return m, nil
-}
-
-var helpWidget = ""
-
-// getHelpWidget returns the help widget with current theme colors
-func getHelpWidget() string {
-	t := theme.CurrentTheme()
-	helpText := "ctrl+? help"
-
-	return styles.Padded().
-		Background(t.TextMuted()).
-		Foreground(t.BackgroundDarker()).
-		Bold(true).
-		Render(helpText)
-}
-
-func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
-	// Format tokens in human-readable format (e.g., 110K, 1.2M)
-	var formattedTokens string
-	switch {
-	case tokens >= 1_000_000:
-		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
-	case tokens >= 1_000:
-		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
-	default:
-		formattedTokens = fmt.Sprintf("%d", tokens)
-	}
-
-	// Remove .0 suffix if present
-	if strings.HasSuffix(formattedTokens, ".0K") {
-		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
-	}
-	if strings.HasSuffix(formattedTokens, ".0M") {
-		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
-	}
-
-	// Format cost with $ symbol and 2 decimal places
-	formattedCost := fmt.Sprintf("$%.2f", cost)
-
-	percentage := (float64(tokens) / float64(contextWindow)) * 100
-	if percentage > 80 {
-		// add the warning icon and percentage
-		formattedTokens = fmt.Sprintf("%s(%d%%)", styles.WarningIcon, int(percentage))
-	}
-
-	return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
-}
-
-func (m statusCmp) View() string {
-	t := theme.CurrentTheme()
-	modelID := config.Get().Agents[config.AgentCoder].Model
-	model := models.SupportedModels[modelID]
-
-	// Initialize the help widget
-	status := getHelpWidget()
-
-	tokenInfoWidth := 0
-	if m.session.ID != "" {
-		totalTokens := m.session.PromptTokens + m.session.CompletionTokens
-		tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost)
-		tokensStyle := styles.Padded().
-			Background(t.Text()).
-			Foreground(t.BackgroundSecondary())
-		percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100
-		if percentage > 80 {
-			tokensStyle = tokensStyle.Background(t.Warning())
-		}
-		tokenInfoWidth = lipgloss.Width(tokens) + 2
-		status += tokensStyle.Render(tokens)
-	}
-
-	diagnostics := styles.Padded().
-		Background(t.BackgroundDarker()).
-		Render(m.projectDiagnostics())
-
-	availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
-
-	if m.info.Msg != "" {
-		infoStyle := styles.Padded().
-			Foreground(t.Background()).
-			Width(availableWidht)
-
-		switch m.info.Type {
-		case util.InfoTypeInfo:
-			infoStyle = infoStyle.Background(t.Info())
-		case util.InfoTypeWarn:
-			infoStyle = infoStyle.Background(t.Warning())
-		case util.InfoTypeError:
-			infoStyle = infoStyle.Background(t.Error())
-		}
-
-		infoWidth := availableWidht - 10
-		// Truncate message if it's longer than available width
-		msg := m.info.Msg
-		if len(msg) > infoWidth && infoWidth > 0 {
-			msg = msg[:infoWidth] + "..."
-		}
-		status += infoStyle.Render(msg)
-	} else {
-		status += styles.Padded().
-			Foreground(t.Text()).
-			Background(t.BackgroundSecondary()).
-			Width(availableWidht).
-			Render("")
-	}
-
-	status += diagnostics
-	status += m.model()
-	return status
-}
-
-func (m *statusCmp) projectDiagnostics() string {
-	t := theme.CurrentTheme()
-
-	// Check if any LSP server is still initializing
-	initializing := false
-	for _, client := range m.lspClients {
-		if client.GetServerState() == lsp.StateStarting {
-			initializing = true
-			break
-		}
-	}
-
-	// If any server is initializing, show that status
-	if initializing {
-		return lipgloss.NewStyle().
-			Background(t.BackgroundDarker()).
-			Foreground(t.Warning()).
-			Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
-	}
-
-	errorDiagnostics := []protocol.Diagnostic{}
-	warnDiagnostics := []protocol.Diagnostic{}
-	hintDiagnostics := []protocol.Diagnostic{}
-	infoDiagnostics := []protocol.Diagnostic{}
-	for _, client := range m.lspClients {
-		for _, d := range client.GetDiagnostics() {
-			for _, diag := range d {
-				switch diag.Severity {
-				case protocol.SeverityError:
-					errorDiagnostics = append(errorDiagnostics, diag)
-				case protocol.SeverityWarning:
-					warnDiagnostics = append(warnDiagnostics, diag)
-				case protocol.SeverityHint:
-					hintDiagnostics = append(hintDiagnostics, diag)
-				case protocol.SeverityInformation:
-					infoDiagnostics = append(infoDiagnostics, diag)
-				}
-			}
-		}
-	}
-
-	if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
-		return "No diagnostics"
-	}
-
-	diagnostics := []string{}
-
-	if len(errorDiagnostics) > 0 {
-		errStr := lipgloss.NewStyle().
-			Background(t.BackgroundDarker()).
-			Foreground(t.Error()).
-			Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
-		diagnostics = append(diagnostics, errStr)
-	}
-	if len(warnDiagnostics) > 0 {
-		warnStr := lipgloss.NewStyle().
-			Background(t.BackgroundDarker()).
-			Foreground(t.Warning()).
-			Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
-		diagnostics = append(diagnostics, warnStr)
-	}
-	if len(hintDiagnostics) > 0 {
-		hintStr := lipgloss.NewStyle().
-			Background(t.BackgroundDarker()).
-			Foreground(t.Text()).
-			Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
-		diagnostics = append(diagnostics, hintStr)
-	}
-	if len(infoDiagnostics) > 0 {
-		infoStr := lipgloss.NewStyle().
-			Background(t.BackgroundDarker()).
-			Foreground(t.Info()).
-			Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
-		diagnostics = append(diagnostics, infoStr)
-	}
-
-	return strings.Join(diagnostics, " ")
-}
-
-func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
-	tokensWidth := 0
-	if m.session.ID != "" {
-		tokensWidth = lipgloss.Width(tokenInfo) + 2
-	}
-	return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
-}
-
-func (m statusCmp) model() string {
-	t := theme.CurrentTheme()
-
-	cfg := config.Get()
-
-	coder, ok := cfg.Agents[config.AgentCoder]
-	if !ok {
-		return "Unknown"
-	}
-	model := models.SupportedModels[coder.Model]
-
-	return styles.Padded().
-		Background(t.Secondary()).
-		Foreground(t.Background()).
-		Render(model.Name)
-}
-
-func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
-	helpWidget = getHelpWidget()
-
-	return &statusCmp{
-		messageTTL: 10 * time.Second,
-		lspClients: lspClients,
-	}
-}

internal/tui/components/core/status/keys.go 🔗

@@ -0,0 +1,55 @@
+package status
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Tab,
+	Commands,
+	Sessions,
+	Help key.Binding
+}
+
+func DefaultKeyMap(tabHelp string) KeyMap {
+	return KeyMap{
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", tabHelp),
+		),
+		Commands: key.NewBinding(
+			key.WithKeys("ctrl+p"),
+			key.WithHelp("ctrl+p", "commands"),
+		),
+		Sessions: key.NewBinding(
+			key.WithKeys("ctrl+s"),
+			key.WithHelp("ctrl+s", "sessions"),
+		),
+		Help: key.NewBinding(
+			key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
+			key.WithHelp("ctrl+?", "more"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Tab,
+		k.Commands,
+		k.Sessions,
+		k.Help,
+	}
+}

internal/tui/components/core/status/status.go 🔗

@@ -0,0 +1,113 @@
+package status
+
+import (
+	"time"
+
+	"github.com/charmbracelet/bubbles/v2/help"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+type StatusCmp interface {
+	util.Model
+}
+
+type statusCmp struct {
+	info       util.InfoMsg
+	width      int
+	messageTTL time.Duration
+	session    session.Session
+	help       help.Model
+}
+
+// clearMessageCmd is a command that clears status messages after a timeout
+func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
+	return tea.Tick(ttl, func(time.Time) tea.Msg {
+		return util.ClearStatusMsg{}
+	})
+}
+
+func (m statusCmp) Init() tea.Cmd {
+	return nil
+}
+
+func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		return m, nil
+
+	// Handle status info
+	case util.InfoMsg:
+		m.info = msg
+		ttl := msg.TTL
+		if ttl == 0 {
+			ttl = m.messageTTL
+		}
+		return m, m.clearMessageCmd(ttl)
+	case util.ClearStatusMsg:
+		m.info = util.InfoMsg{}
+
+	// Handle persistent logs
+	case pubsub.Event[logging.LogMessage]:
+		if msg.Payload.Persist {
+			switch msg.Payload.Level {
+			case "error":
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeError,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			case "info":
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeInfo,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			case "warn":
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeWarn,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			default:
+				m.info = util.InfoMsg{
+					Type: util.InfoTypeInfo,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				}
+			}
+		}
+	}
+	return m, nil
+}
+
+func (m statusCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
+	if m.info.Msg != "" {
+		switch m.info.Type {
+		case util.InfoTypeError:
+			status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg)
+		case util.InfoTypeWarn:
+			status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg)
+		default:
+			status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg)
+		}
+	}
+	return tea.NewView(status)
+}
+
+func NewStatusCmp() StatusCmp {
+	t := styles.CurrentTheme()
+	help := help.New()
+	help.Styles = t.S().Help
+	return &statusCmp{
+		messageTTL: 10 * time.Second,
+		help:       help,
+	}
+}

internal/tui/components/dialog/arguments.go 🔗

@@ -1,257 +0,0 @@
-package dialog
-
-import (
-	"fmt"
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/textinput"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type argumentsDialogKeyMap struct {
-	Enter  key.Binding
-	Escape key.Binding
-}
-
-// ShortHelp implements key.Map.
-func (k argumentsDialogKeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "confirm"),
-		),
-		key.NewBinding(
-			key.WithKeys("esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-	}
-}
-
-// FullHelp implements key.Map.
-func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
-	return [][]key.Binding{k.ShortHelp()}
-}
-
-// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
-type ShowMultiArgumentsDialogMsg struct {
-	CommandID string
-	Content   string
-	ArgNames  []string
-}
-
-// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
-type CloseMultiArgumentsDialogMsg struct {
-	Submit    bool
-	CommandID string
-	Content   string
-	Args      map[string]string
-}
-
-// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
-type MultiArgumentsDialogCmp struct {
-	width, height int
-	inputs        []textinput.Model
-	focusIndex    int
-	keys          argumentsDialogKeyMap
-	commandID     string
-	content       string
-	argNames      []string
-}
-
-// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
-func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
-	t := theme.CurrentTheme()
-	inputs := make([]textinput.Model, len(argNames))
-
-	for i, name := range argNames {
-		ti := textinput.New()
-		ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
-		ti.Width = 40
-		ti.Prompt = ""
-		ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
-		ti.PromptStyle = ti.PromptStyle.Background(t.Background())
-		ti.TextStyle = ti.TextStyle.Background(t.Background())
-		
-		// Only focus the first input initially
-		if i == 0 {
-			ti.Focus()
-			ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
-			ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
-		} else {
-			ti.Blur()
-		}
-
-		inputs[i] = ti
-	}
-
-	return MultiArgumentsDialogCmp{
-		inputs:    inputs,
-		keys:      argumentsDialogKeyMap{},
-		commandID: commandID,
-		content:   content,
-		argNames:  argNames,
-		focusIndex: 0,
-	}
-}
-
-// Init implements tea.Model.
-func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
-	// Make sure only the first input is focused
-	for i := range m.inputs {
-		if i == 0 {
-			m.inputs[i].Focus()
-		} else {
-			m.inputs[i].Blur()
-		}
-	}
-	
-	return textinput.Blink
-}
-
-// Update implements tea.Model.
-func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	t := theme.CurrentTheme()
-
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
-			return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
-				Submit:    false,
-				CommandID: m.commandID,
-				Content:   m.content,
-				Args:      nil,
-			})
-		case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
-			// If we're on the last input, submit the form
-			if m.focusIndex == len(m.inputs)-1 {
-				args := make(map[string]string)
-				for i, name := range m.argNames {
-					args[name] = m.inputs[i].Value()
-				}
-				return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
-					Submit:    true,
-					CommandID: m.commandID,
-					Content:   m.content,
-					Args:      args,
-				})
-			}
-			// Otherwise, move to the next input
-			m.inputs[m.focusIndex].Blur()
-			m.focusIndex++
-			m.inputs[m.focusIndex].Focus()
-			m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
-			m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
-		case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
-			// Move to the next input
-			m.inputs[m.focusIndex].Blur()
-			m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
-			m.inputs[m.focusIndex].Focus()
-			m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
-			m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
-		case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
-			// Move to the previous input
-			m.inputs[m.focusIndex].Blur()
-			m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
-			m.inputs[m.focusIndex].Focus()
-			m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
-			m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
-		}
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.height = msg.Height
-	}
-
-	// Update the focused input
-	var cmd tea.Cmd
-	m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
-	cmds = append(cmds, cmd)
-
-	return m, tea.Batch(cmds...)
-}
-
-// View implements tea.Model.
-func (m MultiArgumentsDialogCmp) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	// Calculate width needed for content
-	maxWidth := 60 // Width for explanation text
-
-	title := lipgloss.NewStyle().
-		Foreground(t.Primary()).
-		Bold(true).
-		Width(maxWidth).
-		Padding(0, 1).
-		Background(t.Background()).
-		Render("Command Arguments")
-
-	explanation := lipgloss.NewStyle().
-		Foreground(t.Text()).
-		Width(maxWidth).
-		Padding(0, 1).
-		Background(t.Background()).
-		Render("This command requires multiple arguments. Please enter values for each:")
-
-	// Create input fields for each argument
-	inputFields := make([]string, len(m.inputs))
-	for i, input := range m.inputs {
-		// Highlight the label of the focused input
-		labelStyle := lipgloss.NewStyle().
-			Width(maxWidth).
-			Padding(1, 1, 0, 1).
-			Background(t.Background())
-			
-		if i == m.focusIndex {
-			labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
-		} else {
-			labelStyle = labelStyle.Foreground(t.TextMuted())
-		}
-		
-		label := labelStyle.Render(m.argNames[i] + ":")
-
-		field := lipgloss.NewStyle().
-			Foreground(t.Text()).
-			Width(maxWidth).
-			Padding(0, 1).
-			Background(t.Background()).
-			Render(input.View())
-
-		inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
-	}
-
-	maxWidth = min(maxWidth, m.width-10)
-
-	// Join all elements vertically
-	elements := []string{title, explanation}
-	elements = append(elements, inputFields...)
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		elements...,
-	)
-
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Background(t.Background()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
-}
-
-// SetSize sets the size of the component.
-func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
-	m.width = width
-	m.height = height
-}
-
-// Bindings implements layout.Bindings.
-func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
-	return m.keys.ShortHelp()
-}

internal/tui/components/dialog/commands.go 🔗

@@ -1,180 +0,0 @@
-package dialog
-
-import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-// Command represents a command that can be executed
-type Command struct {
-	ID          string
-	Title       string
-	Description string
-	Handler     func(cmd Command) tea.Cmd
-}
-
-func (ci Command) Render(selected bool, width int) string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
-	itemStyle := baseStyle.Width(width).
-		Foreground(t.Text()).
-		Background(t.Background())
-
-	if selected {
-		itemStyle = itemStyle.
-			Background(t.Primary()).
-			Foreground(t.Background()).
-			Bold(true)
-		descStyle = descStyle.
-			Background(t.Primary()).
-			Foreground(t.Background())
-	}
-
-	title := itemStyle.Padding(0, 1).Render(ci.Title)
-	if ci.Description != "" {
-		description := descStyle.Padding(0, 1).Render(ci.Description)
-		return lipgloss.JoinVertical(lipgloss.Left, title, description)
-	}
-	return title
-}
-
-// CommandSelectedMsg is sent when a command is selected
-type CommandSelectedMsg struct {
-	Command Command
-}
-
-// CloseCommandDialogMsg is sent when the command dialog is closed
-type CloseCommandDialogMsg struct{}
-
-// CommandDialog interface for the command selection dialog
-type CommandDialog interface {
-	tea.Model
-	layout.Bindings
-	SetCommands(commands []Command)
-}
-
-type commandDialogCmp struct {
-	listView utilComponents.SimpleList[Command]
-	width    int
-	height   int
-}
-
-type commandKeyMap struct {
-	Enter  key.Binding
-	Escape key.Binding
-}
-
-var commandKeys = commandKeyMap{
-	Enter: key.NewBinding(
-		key.WithKeys("enter"),
-		key.WithHelp("enter", "select command"),
-	),
-	Escape: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "close"),
-	),
-}
-
-func (c *commandDialogCmp) Init() tea.Cmd {
-	return c.listView.Init()
-}
-
-func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, commandKeys.Enter):
-			selectedItem, idx := c.listView.GetSelectedItem()
-			if idx != -1 {
-				return c, util.CmdHandler(CommandSelectedMsg{
-					Command: selectedItem,
-				})
-			}
-		case key.Matches(msg, commandKeys.Escape):
-			return c, util.CmdHandler(CloseCommandDialogMsg{})
-		}
-	case tea.WindowSizeMsg:
-		c.width = msg.Width
-		c.height = msg.Height
-	}
-
-	u, cmd := c.listView.Update(msg)
-	c.listView = u.(utilComponents.SimpleList[Command])
-	cmds = append(cmds, cmd)
-
-	return c, tea.Batch(cmds...)
-}
-
-func (c *commandDialogCmp) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	maxWidth := 40
-
-	commands := c.listView.GetItems()
-
-	for _, cmd := range commands {
-		if len(cmd.Title) > maxWidth-4 {
-			maxWidth = len(cmd.Title) + 4
-		}
-		if cmd.Description != "" {
-			if len(cmd.Description) > maxWidth-4 {
-				maxWidth = len(cmd.Description) + 4
-			}
-		}
-	}
-
-	c.listView.SetMaxWidth(maxWidth)
-
-	title := baseStyle.
-		Foreground(t.Primary()).
-		Bold(true).
-		Width(maxWidth).
-		Padding(0, 1).
-		Render("Commands")
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		title,
-		baseStyle.Width(maxWidth).Render(""),
-		baseStyle.Width(maxWidth).Render(c.listView.View()),
-		baseStyle.Width(maxWidth).Render(""),
-	)
-
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
-}
-
-func (c *commandDialogCmp) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(commandKeys)
-}
-
-func (c *commandDialogCmp) SetCommands(commands []Command) {
-	c.listView.SetItems(commands)
-}
-
-// NewCommandDialogCmp creates a new command selection dialog
-func NewCommandDialogCmp() CommandDialog {
-	listView := utilComponents.NewSimpleList[Command](
-		[]Command{},
-		10,
-		"No commands available",
-		true,
-	)
-	return &commandDialogCmp{
-		listView: listView,
-	}
-}

internal/tui/components/dialog/complete.go 🔗

@@ -1,264 +0,0 @@
-package dialog
-
-import (
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/textarea"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/logging"
-	utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type CompletionItem struct {
-	title string
-	Title string
-	Value string
-}
-
-type CompletionItemI interface {
-	utilComponents.SimpleListItem
-	GetValue() string
-	DisplayValue() string
-}
-
-func (ci *CompletionItem) Render(selected bool, width int) string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	itemStyle := baseStyle.
-		Width(width).
-		Padding(0, 1)
-
-	if selected {
-		itemStyle = itemStyle.
-			Background(t.Background()).
-			Foreground(t.Primary()).
-			Bold(true)
-	}
-
-	title := itemStyle.Render(
-		ci.GetValue(),
-	)
-
-	return title
-}
-
-func (ci *CompletionItem) DisplayValue() string {
-	return ci.Title
-}
-
-func (ci *CompletionItem) GetValue() string {
-	return ci.Value
-}
-
-func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
-	return &completionItem
-}
-
-type CompletionProvider interface {
-	GetId() string
-	GetEntry() CompletionItemI
-	GetChildEntries(query string) ([]CompletionItemI, error)
-}
-
-type CompletionSelectedMsg struct {
-	SearchString    string
-	CompletionValue string
-}
-
-type CompletionDialogCompleteItemMsg struct {
-	Value string
-}
-
-type CompletionDialogCloseMsg struct{}
-
-type CompletionDialog interface {
-	tea.Model
-	layout.Bindings
-	SetWidth(width int)
-}
-
-type completionDialogCmp struct {
-	query                string
-	completionProvider   CompletionProvider
-	width                int
-	height               int
-	pseudoSearchTextArea textarea.Model
-	listView             utilComponents.SimpleList[CompletionItemI]
-}
-
-type completionDialogKeyMap struct {
-	Complete key.Binding
-	Cancel   key.Binding
-}
-
-var completionDialogKeys = completionDialogKeyMap{
-	Complete: key.NewBinding(
-		key.WithKeys("tab", "enter"),
-	),
-	Cancel: key.NewBinding(
-		key.WithKeys(" ", "esc", "backspace"),
-	),
-}
-
-func (c *completionDialogCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
-	value := c.pseudoSearchTextArea.Value()
-
-	if value == "" {
-		return nil
-	}
-
-	return tea.Batch(
-		util.CmdHandler(CompletionSelectedMsg{
-			SearchString:    value,
-			CompletionValue: item.GetValue(),
-		}),
-		c.close(),
-	)
-}
-
-func (c *completionDialogCmp) close() tea.Cmd {
-	c.listView.SetItems([]CompletionItemI{})
-	c.pseudoSearchTextArea.Reset()
-	c.pseudoSearchTextArea.Blur()
-
-	return util.CmdHandler(CompletionDialogCloseMsg{})
-}
-
-func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		if c.pseudoSearchTextArea.Focused() {
-
-			if !key.Matches(msg, completionDialogKeys.Complete) {
-
-				var cmd tea.Cmd
-				c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
-				cmds = append(cmds, cmd)
-
-				var query string
-				query = c.pseudoSearchTextArea.Value()
-				if query != "" {
-					query = query[1:]
-				}
-
-				if query != c.query {
-					logging.Info("Query", query)
-					items, err := c.completionProvider.GetChildEntries(query)
-					if err != nil {
-						logging.Error("Failed to get child entries", err)
-					}
-
-					c.listView.SetItems(items)
-					c.query = query
-				}
-
-				u, cmd := c.listView.Update(msg)
-				c.listView = u.(utilComponents.SimpleList[CompletionItemI])
-
-				cmds = append(cmds, cmd)
-			}
-
-			switch {
-			case key.Matches(msg, completionDialogKeys.Complete):
-				item, i := c.listView.GetSelectedItem()
-				if i == -1 {
-					return c, nil
-				}
-
-				cmd := c.complete(item)
-
-				return c, cmd
-			case key.Matches(msg, completionDialogKeys.Cancel):
-				// Only close on backspace when there are no characters left
-				if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
-					return c, c.close()
-				}
-			}
-
-			return c, tea.Batch(cmds...)
-		} else {
-			items, err := c.completionProvider.GetChildEntries("")
-			if err != nil {
-				logging.Error("Failed to get child entries", err)
-			}
-
-			c.listView.SetItems(items)
-			c.pseudoSearchTextArea.SetValue(msg.String())
-			return c, c.pseudoSearchTextArea.Focus()
-		}
-	case tea.WindowSizeMsg:
-		c.width = msg.Width
-		c.height = msg.Height
-	}
-
-	return c, tea.Batch(cmds...)
-}
-
-func (c *completionDialogCmp) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	maxWidth := 40
-
-	completions := c.listView.GetItems()
-
-	for _, cmd := range completions {
-		title := cmd.DisplayValue()
-		if len(title) > maxWidth-4 {
-			maxWidth = len(title) + 4
-		}
-	}
-
-	c.listView.SetMaxWidth(maxWidth)
-
-	return baseStyle.Padding(0, 0).
-		Border(lipgloss.NormalBorder()).
-		BorderBottom(false).
-		BorderRight(false).
-		BorderLeft(false).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(c.width).
-		Render(c.listView.View())
-}
-
-func (c *completionDialogCmp) SetWidth(width int) {
-	c.width = width
-}
-
-func (c *completionDialogCmp) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(completionDialogKeys)
-}
-
-func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
-	ti := textarea.New()
-
-	items, err := completionProvider.GetChildEntries("")
-	if err != nil {
-		logging.Error("Failed to get child entries", err)
-	}
-
-	li := utilComponents.NewSimpleList(
-		items,
-		7,
-		"No file matches found",
-		false,
-	)
-
-	return &completionDialogCmp{
-		query:                "",
-		completionProvider:   completionProvider,
-		pseudoSearchTextArea: ti,
-		listView:             li,
-	}
-}

internal/tui/components/dialog/custom_commands.go 🔗

@@ -1,186 +0,0 @@
-package dialog
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-	"regexp"
-	"strings"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-// Command prefix constants
-const (
-	UserCommandPrefix    = "user:"
-	ProjectCommandPrefix = "project:"
-)
-
-// namedArgPattern is a regex pattern to find named arguments in the format $NAME
-var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
-
-// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
-func LoadCustomCommands() ([]Command, error) {
-	cfg := config.Get()
-	if cfg == nil {
-		return nil, fmt.Errorf("config not loaded")
-	}
-
-	var commands []Command
-
-	// Load user commands from XDG_CONFIG_HOME/opencode/commands
-	xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
-	if xdgConfigHome == "" {
-		// Default to ~/.config if XDG_CONFIG_HOME is not set
-		home, err := os.UserHomeDir()
-		if err == nil {
-			xdgConfigHome = filepath.Join(home, ".config")
-		}
-	}
-
-	if xdgConfigHome != "" {
-		userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands")
-		userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix)
-		if err != nil {
-			// Log error but continue - we'll still try to load other commands
-			fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err)
-		} else {
-			commands = append(commands, userCommands...)
-		}
-	}
-
-	// Load commands from $HOME/.opencode/commands
-	home, err := os.UserHomeDir()
-	if err == nil {
-		homeCommandsDir := filepath.Join(home, ".opencode", "commands")
-		homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
-		if err != nil {
-			// Log error but continue - we'll still try to load other commands
-			fmt.Printf("Warning: failed to load home commands: %v\n", err)
-		} else {
-			commands = append(commands, homeCommands...)
-		}
-	}
-
-	// Load project commands from data directory
-	projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands")
-	projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
-	if err != nil {
-		// Log error but return what we have so far
-		fmt.Printf("Warning: failed to load project commands: %v\n", err)
-	} else {
-		commands = append(commands, projectCommands...)
-	}
-
-	return commands, nil
-}
-
-// loadCommandsFromDir loads commands from a specific directory with the given prefix
-func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
-	// Check if the commands directory exists
-	if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
-		// Create the commands directory if it doesn't exist
-		if err := os.MkdirAll(commandsDir, 0755); err != nil {
-			return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
-		}
-		// Return empty list since we just created the directory
-		return []Command{}, nil
-	}
-
-	var commands []Command
-
-	// Walk through the commands directory and load all .md files
-	err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-
-		// Skip directories
-		if info.IsDir() {
-			return nil
-		}
-
-		// Only process markdown files
-		if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
-			return nil
-		}
-
-		// Read the file content
-		content, err := os.ReadFile(path)
-		if err != nil {
-			return fmt.Errorf("failed to read command file %s: %w", path, err)
-		}
-
-		// Get the command ID from the file name without the .md extension
-		commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
-
-		// Get relative path from commands directory
-		relPath, err := filepath.Rel(commandsDir, path)
-		if err != nil {
-			return fmt.Errorf("failed to get relative path for %s: %w", path, err)
-		}
-
-		// Create the command ID from the relative path
-		// Replace directory separators with colons
-		commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
-		if commandIDPath != "." {
-			commandID = commandIDPath + ":" + commandID
-		}
-
-		// Create a command
-		command := Command{
-			ID:          prefix + commandID,
-			Title:       prefix + commandID,
-			Description: fmt.Sprintf("Custom command from %s", relPath),
-			Handler: func(cmd Command) tea.Cmd {
-				commandContent := string(content)
-
-				// Check for named arguments
-				matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
-				if len(matches) > 0 {
-					// Extract unique argument names
-					argNames := make([]string, 0)
-					argMap := make(map[string]bool)
-
-					for _, match := range matches {
-						argName := match[1] // Group 1 is the name without $
-						if !argMap[argName] {
-							argMap[argName] = true
-							argNames = append(argNames, argName)
-						}
-					}
-
-					// Show multi-arguments dialog for all named arguments
-					return util.CmdHandler(ShowMultiArgumentsDialogMsg{
-						CommandID: cmd.ID,
-						Content:   commandContent,
-						ArgNames:  argNames,
-					})
-				}
-
-				// No arguments needed, run command directly
-				return util.CmdHandler(CommandRunCustomMsg{
-					Content: commandContent,
-					Args:    nil, // No arguments
-				})
-			},
-		}
-
-		commands = append(commands, command)
-		return nil
-	})
-
-	if err != nil {
-		return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
-	}
-
-	return commands, nil
-}
-
-// CommandRunCustomMsg is sent when a custom command is executed
-type CommandRunCustomMsg struct {
-	Content string
-	Args    map[string]string // Map of argument names to values
-}

internal/tui/components/dialog/custom_commands_test.go 🔗

@@ -1,106 +0,0 @@
-package dialog
-
-import (
-	"testing"
-	"regexp"
-)
-
-func TestNamedArgPattern(t *testing.T) {
-	testCases := []struct {
-		input    string
-		expected []string
-	}{
-		{
-			input:    "This is a test with $ARGUMENTS placeholder",
-			expected: []string{"ARGUMENTS"},
-		},
-		{
-			input:    "This is a test with $FOO and $BAR placeholders",
-			expected: []string{"FOO", "BAR"},
-		},
-		{
-			input:    "This is a test with $FOO_BAR and $BAZ123 placeholders",
-			expected: []string{"FOO_BAR", "BAZ123"},
-		},
-		{
-			input:    "This is a test with no placeholders",
-			expected: []string{},
-		},
-		{
-			input:    "This is a test with $FOO appearing twice: $FOO",
-			expected: []string{"FOO"},
-		},
-		{
-			input:    "This is a test with $1INVALID placeholder",
-			expected: []string{},
-		},
-	}
-
-	for _, tc := range testCases {
-		matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
-		
-		// Extract unique argument names
-		argNames := make([]string, 0)
-		argMap := make(map[string]bool)
-		
-		for _, match := range matches {
-			argName := match[1] // Group 1 is the name without $
-			if !argMap[argName] {
-				argMap[argName] = true
-				argNames = append(argNames, argName)
-			}
-		}
-		
-		// Check if we got the expected number of arguments
-		if len(argNames) != len(tc.expected) {
-			t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
-			continue
-		}
-		
-		// Check if we got the expected argument names
-		for _, expectedArg := range tc.expected {
-			found := false
-			for _, actualArg := range argNames {
-				if actualArg == expectedArg {
-					found = true
-					break
-				}
-			}
-			if !found {
-				t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
-			}
-		}
-	}
-}
-
-func TestRegexPattern(t *testing.T) {
-	pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
-	
-	validMatches := []string{
-		"$FOO",
-		"$BAR",
-		"$FOO_BAR",
-		"$BAZ123",
-		"$ARGUMENTS",
-	}
-	
-	invalidMatches := []string{
-		"$foo",
-		"$1BAR",
-		"$_FOO",
-		"FOO",
-		"$",
-	}
-	
-	for _, valid := range validMatches {
-		if !pattern.MatchString(valid) {
-			t.Errorf("Expected %s to match, but it didn't", valid)
-		}
-	}
-	
-	for _, invalid := range invalidMatches {
-		if pattern.MatchString(invalid) {
-			t.Errorf("Expected %s not to match, but it did", invalid)
-		}
-	}
-}

internal/tui/components/dialog/filepicker.go 🔗

@@ -1,471 +0,0 @@
-package dialog
-
-import (
-	"fmt"
-	"net/http"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/textinput"
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/tui/image"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-const (
-	maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
-	downArrow         = "down"
-	upArrow           = "up"
-)
-
-type FilePrickerKeyMap struct {
-	Enter          key.Binding
-	Down           key.Binding
-	Up             key.Binding
-	Forward        key.Binding
-	Backward       key.Binding
-	OpenFilePicker key.Binding
-	Esc            key.Binding
-	InsertCWD      key.Binding
-}
-
-var filePickerKeyMap = FilePrickerKeyMap{
-	Enter: key.NewBinding(
-		key.WithKeys("enter"),
-		key.WithHelp("enter", "select file/enter directory"),
-	),
-	Down: key.NewBinding(
-		key.WithKeys("j", downArrow),
-		key.WithHelp("↓/j", "down"),
-	),
-	Up: key.NewBinding(
-		key.WithKeys("k", upArrow),
-		key.WithHelp("↑/k", "up"),
-	),
-	Forward: key.NewBinding(
-		key.WithKeys("l"),
-		key.WithHelp("l", "enter directory"),
-	),
-	Backward: key.NewBinding(
-		key.WithKeys("h", "backspace"),
-		key.WithHelp("h/backspace", "go back"),
-	),
-	OpenFilePicker: key.NewBinding(
-		key.WithKeys("ctrl+f"),
-		key.WithHelp("ctrl+f", "open file picker"),
-	),
-	Esc: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "close/exit"),
-	),
-	InsertCWD: key.NewBinding(
-		key.WithKeys("i"),
-		key.WithHelp("i", "manual path input"),
-	),
-}
-
-type filepickerCmp struct {
-	basePath       string
-	width          int
-	height         int
-	cursor         int
-	err            error
-	cursorChain    stack
-	viewport       viewport.Model
-	dirs           []os.DirEntry
-	cwdDetails     *DirNode
-	selectedFile   string
-	cwd            textinput.Model
-	ShowFilePicker bool
-	app            *app.App
-}
-
-type DirNode struct {
-	parent    *DirNode
-	child     *DirNode
-	directory string
-}
-type stack []int
-
-func (s stack) Push(v int) stack {
-	return append(s, v)
-}
-
-func (s stack) Pop() (stack, int) {
-	l := len(s)
-	return s[:l-1], s[l-1]
-}
-
-type AttachmentAddedMsg struct {
-	Attachment message.Attachment
-}
-
-func (f *filepickerCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmd tea.Cmd
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		f.width = 60
-		f.height = 20
-		f.viewport.Width = 80
-		f.viewport.Height = 22
-		f.cursor = 0
-		f.getCurrentFileBelowCursor()
-	case tea.KeyMsg:
-		if f.cwd.Focused() {
-			f.cwd, cmd = f.cwd.Update(msg)
-		}
-		switch {
-		case key.Matches(msg, filePickerKeyMap.InsertCWD):
-			f.cwd.Focus()
-			return f, cmd
-		case key.Matches(msg, filePickerKeyMap.Esc):
-			if f.cwd.Focused() {
-				f.cwd.Blur()
-			}
-		case key.Matches(msg, filePickerKeyMap.Down):
-			if !f.cwd.Focused() || msg.String() == downArrow {
-				if f.cursor < len(f.dirs)-1 {
-					f.cursor++
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Up):
-			if !f.cwd.Focused() || msg.String() == upArrow {
-				if f.cursor > 0 {
-					f.cursor--
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Enter):
-			var path string
-			var isPathDir bool
-			if f.cwd.Focused() {
-				path = f.cwd.Value()
-				fileInfo, err := os.Stat(path)
-				if err != nil {
-					logging.ErrorPersist("Invalid path")
-					return f, cmd
-				}
-				isPathDir = fileInfo.IsDir()
-			} else {
-				path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
-				isPathDir = f.dirs[f.cursor].IsDir()
-			}
-			if isPathDir {
-				newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
-				f.cwdDetails.child = &newWorkingDir
-				f.cwdDetails = f.cwdDetails.child
-				f.cursorChain = f.cursorChain.Push(f.cursor)
-				f.dirs = readDir(f.cwdDetails.directory, false)
-				f.cursor = 0
-				f.cwd.SetValue(f.cwdDetails.directory)
-				f.getCurrentFileBelowCursor()
-			} else {
-				f.selectedFile = path
-				return f.addAttachmentToMessage()
-			}
-		case key.Matches(msg, filePickerKeyMap.Esc):
-			if !f.cwd.Focused() {
-				f.cursorChain = make(stack, 0)
-				f.cursor = 0
-			} else {
-				f.cwd.Blur()
-			}
-		case key.Matches(msg, filePickerKeyMap.Forward):
-			if !f.cwd.Focused() {
-				if f.dirs[f.cursor].IsDir() {
-					path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
-					newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
-					f.cwdDetails.child = &newWorkingDir
-					f.cwdDetails = f.cwdDetails.child
-					f.cursorChain = f.cursorChain.Push(f.cursor)
-					f.dirs = readDir(f.cwdDetails.directory, false)
-					f.cursor = 0
-					f.cwd.SetValue(f.cwdDetails.directory)
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.Backward):
-			if !f.cwd.Focused() {
-				if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
-					f.cursorChain, f.cursor = f.cursorChain.Pop()
-					f.cwdDetails = f.cwdDetails.parent
-					f.cwdDetails.child = nil
-					f.dirs = readDir(f.cwdDetails.directory, false)
-					f.cwd.SetValue(f.cwdDetails.directory)
-					f.getCurrentFileBelowCursor()
-				}
-			}
-		case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
-			f.dirs = readDir(f.cwdDetails.directory, false)
-			f.cursor = 0
-			f.getCurrentFileBelowCursor()
-		}
-	}
-	return f, cmd
-}
-
-func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
-	modeInfo := GetSelectedModel(config.Get())
-	if !modeInfo.SupportsAttachments {
-		logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
-		return f, nil
-	}
-
-	selectedFilePath := f.selectedFile
-	if !isExtSupported(selectedFilePath) {
-		logging.ErrorPersist("Unsupported file")
-		return f, nil
-	}
-
-	isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
-	if err != nil {
-		logging.ErrorPersist("unable to read the image")
-		return f, nil
-	}
-	if isFileLarge {
-		logging.ErrorPersist("file too large, max 5MB")
-		return f, nil
-	}
-
-	content, err := os.ReadFile(selectedFilePath)
-	if err != nil {
-		logging.ErrorPersist("Unable read selected file")
-		return f, nil
-	}
-
-	mimeBufferSize := min(512, len(content))
-	mimeType := http.DetectContentType(content[:mimeBufferSize])
-	fileName := filepath.Base(selectedFilePath)
-	attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
-	f.selectedFile = ""
-	return f, util.CmdHandler(AttachmentAddedMsg{attachment})
-}
-
-func (f *filepickerCmp) View() string {
-	t := theme.CurrentTheme()
-	const maxVisibleDirs = 20
-	const maxWidth = 80
-
-	adjustedWidth := maxWidth
-	for _, file := range f.dirs {
-		if len(file.Name()) > adjustedWidth-4 { // Account for padding
-			adjustedWidth = len(file.Name()) + 4
-		}
-	}
-	adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
-
-	files := make([]string, 0, maxVisibleDirs)
-	startIdx := 0
-
-	if len(f.dirs) > maxVisibleDirs {
-		halfVisible := maxVisibleDirs / 2
-		if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
-			startIdx = f.cursor - halfVisible
-		} else if f.cursor >= len(f.dirs)-halfVisible {
-			startIdx = len(f.dirs) - maxVisibleDirs
-		}
-	}
-
-	endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
-
-	for i := startIdx; i < endIdx; i++ {
-		file := f.dirs[i]
-		itemStyle := styles.BaseStyle().Width(adjustedWidth)
-
-		if i == f.cursor {
-			itemStyle = itemStyle.
-				Background(t.Primary()).
-				Foreground(t.Background()).
-				Bold(true)
-		}
-		filename := file.Name()
-
-		if len(filename) > adjustedWidth-4 {
-			filename = filename[:adjustedWidth-7] + "..."
-		}
-		if file.IsDir() {
-			filename = filename + "/"
-		}
-		// No need to reassign filename if it's not changing
-
-		files = append(files, itemStyle.Padding(0, 1).Render(filename))
-	}
-
-	// Pad to always show exactly 21 lines
-	for len(files) < maxVisibleDirs {
-		files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
-	}
-
-	currentPath := styles.BaseStyle().
-		Height(1).
-		Width(adjustedWidth).
-		Render(f.cwd.View())
-
-	viewportstyle := lipgloss.NewStyle().
-		Width(f.viewport.Width).
-		Background(t.Background()).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.TextMuted()).
-		BorderBackground(t.Background()).
-		Padding(2).
-		Render(f.viewport.View())
-	var insertExitText string
-	if f.IsCWDFocused() {
-		insertExitText = "Press esc to exit typing path"
-	} else {
-		insertExitText = "Press i to start typing path"
-	}
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		currentPath,
-		styles.BaseStyle().Width(adjustedWidth).Render(""),
-		styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
-		styles.BaseStyle().Width(adjustedWidth).Render(""),
-		styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
-	)
-
-	f.cwd.SetValue(f.cwd.Value())
-	contentStyle := styles.BaseStyle().Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4)
-
-	return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
-}
-
-type FilepickerCmp interface {
-	tea.Model
-	ToggleFilepicker(showFilepicker bool)
-	IsCWDFocused() bool
-}
-
-func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
-	f.ShowFilePicker = showFilepicker
-}
-
-func (f *filepickerCmp) IsCWDFocused() bool {
-	return f.cwd.Focused()
-}
-
-func NewFilepickerCmp(app *app.App) FilepickerCmp {
-	homepath, err := os.UserHomeDir()
-	if err != nil {
-		logging.Error("error loading user files")
-		return nil
-	}
-	baseDir := DirNode{parent: nil, directory: homepath}
-	dirs := readDir(homepath, false)
-	viewport := viewport.New(0, 0)
-	currentDirectory := textinput.New()
-	currentDirectory.CharLimit = 200
-	currentDirectory.Width = 44
-	currentDirectory.Cursor.Blink = true
-	currentDirectory.SetValue(baseDir.directory)
-	return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
-}
-
-func (f *filepickerCmp) getCurrentFileBelowCursor() {
-	if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
-		logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
-		f.viewport.SetContent("Preview unavailable")
-		return
-	}
-
-	dir := f.dirs[f.cursor]
-	filename := dir.Name()
-	if !dir.IsDir() && isExtSupported(filename) {
-		fullPath := f.cwdDetails.directory + "/" + dir.Name()
-
-		go func() {
-			imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
-			if err != nil {
-				logging.Error(err.Error())
-				f.viewport.SetContent("Preview unavailable")
-				return
-			}
-
-			f.viewport.SetContent(imageString)
-		}()
-	} else {
-		f.viewport.SetContent("Preview unavailable")
-	}
-}
-
-func readDir(path string, showHidden bool) []os.DirEntry {
-	logging.Info(fmt.Sprintf("Reading directory: %s", path))
-
-	entriesChan := make(chan []os.DirEntry, 1)
-	errChan := make(chan error, 1)
-
-	go func() {
-		dirEntries, err := os.ReadDir(path)
-		if err != nil {
-			logging.ErrorPersist(err.Error())
-			errChan <- err
-			return
-		}
-		entriesChan <- dirEntries
-	}()
-
-	select {
-	case dirEntries := <-entriesChan:
-		sort.Slice(dirEntries, func(i, j int) bool {
-			if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
-				return dirEntries[i].Name() < dirEntries[j].Name()
-			}
-			return dirEntries[i].IsDir()
-		})
-
-		if showHidden {
-			return dirEntries
-		}
-
-		var sanitizedDirEntries []os.DirEntry
-		for _, dirEntry := range dirEntries {
-			isHidden, _ := IsHidden(dirEntry.Name())
-			if !isHidden {
-				if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
-					sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
-				}
-			}
-		}
-
-		return sanitizedDirEntries
-
-	case err := <-errChan:
-		logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
-		return []os.DirEntry{}
-
-	case <-time.After(5 * time.Second):
-		logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
-		return []os.DirEntry{}
-	}
-}
-
-func IsHidden(file string) (bool, error) {
-	return strings.HasPrefix(file, "."), nil
-}
-
-func isExtSupported(path string) bool {
-	ext := strings.ToLower(filepath.Ext(path))
-	return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
-}

internal/tui/components/dialog/help.go 🔗

@@ -1,200 +0,0 @@
-package dialog
-
-import (
-	"strings"
-
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-)
-
-type helpCmp struct {
-	width  int
-	height int
-	keys   []key.Binding
-}
-
-func (h *helpCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (h *helpCmp) SetBindings(k []key.Binding) {
-	h.keys = k
-}
-
-func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		h.width = 90
-		h.height = msg.Height
-	}
-	return h, nil
-}
-
-func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
-	seen := make(map[string]struct{})
-	result := make([]key.Binding, 0, len(bindings))
-
-	// Process bindings in reverse order
-	for i := len(bindings) - 1; i >= 0; i-- {
-		b := bindings[i]
-		k := strings.Join(b.Keys(), " ")
-		if _, ok := seen[k]; ok {
-			// duplicate, skip
-			continue
-		}
-		seen[k] = struct{}{}
-		// Add to the beginning of result to maintain original order
-		result = append([]key.Binding{b}, result...)
-	}
-
-	return result
-}
-
-func (h *helpCmp) render() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	helpKeyStyle := styles.Bold().
-		Background(t.Background()).
-		Foreground(t.Text()).
-		Padding(0, 1, 0, 0)
-
-	helpDescStyle := styles.Regular().
-		Background(t.Background()).
-		Foreground(t.TextMuted())
-
-	// Compile list of bindings to render
-	bindings := removeDuplicateBindings(h.keys)
-
-	// Enumerate through each group of bindings, populating a series of
-	// pairs of columns, one for keys, one for descriptions
-	var (
-		pairs []string
-		width int
-		rows  = 12 - 2
-	)
-
-	for i := 0; i < len(bindings); i += rows {
-		var (
-			keys  []string
-			descs []string
-		)
-		for j := i; j < min(i+rows, len(bindings)); j++ {
-			keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
-			descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
-		}
-
-		// Render pair of columns; beyond the first pair, render a three space
-		// left margin, in order to visually separate the pairs.
-		var cols []string
-		if len(pairs) > 0 {
-			cols = []string{baseStyle.Render("   ")}
-		}
-
-		maxDescWidth := 0
-		for _, desc := range descs {
-			if maxDescWidth < lipgloss.Width(desc) {
-				maxDescWidth = lipgloss.Width(desc)
-			}
-		}
-		for i := range descs {
-			remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
-			if remainingWidth > 0 {
-				descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
-			}
-		}
-		maxKeyWidth := 0
-		for _, key := range keys {
-			if maxKeyWidth < lipgloss.Width(key) {
-				maxKeyWidth = lipgloss.Width(key)
-			}
-		}
-		for i := range keys {
-			remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
-			if remainingWidth > 0 {
-				keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
-			}
-		}
-
-		cols = append(cols,
-			strings.Join(keys, "\n"),
-			strings.Join(descs, "\n"),
-		)
-
-		pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
-		// check whether it exceeds the maximum width avail (the width of the
-		// terminal, subtracting 2 for the borders).
-		width += lipgloss.Width(pair)
-		if width > h.width-2 {
-			break
-		}
-		pairs = append(pairs, pair)
-	}
-
-	// https://github.com/charmbracelet/lipgloss/issues/209
-	if len(pairs) > 1 {
-		prefix := pairs[:len(pairs)-1]
-		lastPair := pairs[len(pairs)-1]
-		prefix = append(prefix, lipgloss.Place(
-			lipgloss.Width(lastPair),   // width
-			lipgloss.Height(prefix[0]), // height
-			lipgloss.Left,              // x
-			lipgloss.Top,               // y
-			lastPair,                   // content
-			lipgloss.WithWhitespaceBackground(t.Background()),
-		))
-		content := baseStyle.Width(h.width).Render(
-			lipgloss.JoinHorizontal(
-				lipgloss.Top,
-				prefix...,
-			),
-		)
-		return content
-	}
-
-	// Join pairs of columns and enclose in a border
-	content := baseStyle.Width(h.width).Render(
-		lipgloss.JoinHorizontal(
-			lipgloss.Top,
-			pairs...,
-		),
-	)
-	return content
-}
-
-func (h *helpCmp) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	content := h.render()
-	header := baseStyle.
-		Bold(true).
-		Width(lipgloss.Width(content)).
-		Foreground(t.Primary()).
-		Render("Keyboard Shortcuts")
-
-	return baseStyle.Padding(1).
-		Border(lipgloss.RoundedBorder()).
-		BorderForeground(t.TextMuted()).
-		Width(h.width).
-		BorderBackground(t.Background()).
-		Render(
-			lipgloss.JoinVertical(lipgloss.Center,
-				header,
-				baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
-				content,
-			),
-		)
-}
-
-type HelpCmp interface {
-	tea.Model
-	SetBindings([]key.Binding)
-}
-
-func NewHelpCmp() HelpCmp {
-	return &helpCmp{}
-}

internal/tui/components/dialog/init.go 🔗

@@ -1,189 +0,0 @@
-package dialog
-
-import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-// InitDialogCmp is a component that asks the user if they want to initialize the project.
-type InitDialogCmp struct {
-	width, height int
-	selected      int
-	keys          initDialogKeyMap
-}
-
-// NewInitDialogCmp creates a new InitDialogCmp.
-func NewInitDialogCmp() InitDialogCmp {
-	return InitDialogCmp{
-		selected: 0,
-		keys:     initDialogKeyMap{},
-	}
-}
-
-type initDialogKeyMap struct {
-	Tab    key.Binding
-	Left   key.Binding
-	Right  key.Binding
-	Enter  key.Binding
-	Escape key.Binding
-	Y      key.Binding
-	N      key.Binding
-}
-
-// ShortHelp implements key.Map.
-func (k initDialogKeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{
-		key.NewBinding(
-			key.WithKeys("tab", "left", "right"),
-			key.WithHelp("tab/←/→", "toggle selection"),
-		),
-		key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "confirm"),
-		),
-		key.NewBinding(
-			key.WithKeys("esc", "q"),
-			key.WithHelp("esc/q", "cancel"),
-		),
-		key.NewBinding(
-			key.WithKeys("y", "n"),
-			key.WithHelp("y/n", "yes/no"),
-		),
-	}
-}
-
-// FullHelp implements key.Map.
-func (k initDialogKeyMap) FullHelp() [][]key.Binding {
-	return [][]key.Binding{k.ShortHelp()}
-}
-
-// Init implements tea.Model.
-func (m InitDialogCmp) Init() tea.Cmd {
-	return nil
-}
-
-// Update implements tea.Model.
-func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
-			return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
-		case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
-			m.selected = (m.selected + 1) % 2
-			return m, nil
-		case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
-			return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
-		case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
-			return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
-		case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
-			return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
-		}
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.height = msg.Height
-	}
-	return m, nil
-}
-
-// View implements tea.Model.
-func (m InitDialogCmp) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-	
-	// Calculate width needed for content
-	maxWidth := 60 // Width for explanation text
-
-	title := baseStyle.
-		Foreground(t.Primary()).
-		Bold(true).
-		Width(maxWidth).
-		Padding(0, 1).
-		Render("Initialize Project")
-
-	explanation := baseStyle.
-		Foreground(t.Text()).
-		Width(maxWidth).
-		Padding(0, 1).
-		Render("Initialization generates a new OpenCode.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
-
-	question := baseStyle.
-		Foreground(t.Text()).
-		Width(maxWidth).
-		Padding(1, 1).
-		Render("Would you like to initialize this project?")
-
-	maxWidth = min(maxWidth, m.width-10)
-	yesStyle := baseStyle
-	noStyle := baseStyle
-
-	if m.selected == 0 {
-		yesStyle = yesStyle.
-			Background(t.Primary()).
-			Foreground(t.Background()).
-			Bold(true)
-		noStyle = noStyle.
-			Background(t.Background()).
-			Foreground(t.Primary())
-	} else {
-		noStyle = noStyle.
-			Background(t.Primary()).
-			Foreground(t.Background()).
-			Bold(true)
-		yesStyle = yesStyle.
-			Background(t.Background()).
-			Foreground(t.Primary())
-	}
-
-	yes := yesStyle.Padding(0, 3).Render("Yes")
-	no := noStyle.Padding(0, 3).Render("No")
-
-	buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render("  "), no)
-	buttons = baseStyle.
-		Width(maxWidth).
-		Padding(1, 0).
-		Render(buttons)
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		title,
-		baseStyle.Width(maxWidth).Render(""),
-		explanation,
-		question,
-		buttons,
-		baseStyle.Width(maxWidth).Render(""),
-	)
-
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
-}
-
-// SetSize sets the size of the component.
-func (m *InitDialogCmp) SetSize(width, height int) {
-	m.width = width
-	m.height = height
-}
-
-// Bindings implements layout.Bindings.
-func (m InitDialogCmp) Bindings() []key.Binding {
-	return m.keys.ShortHelp()
-}
-
-// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
-type CloseInitDialogMsg struct {
-	Initialize bool
-}
-
-// ShowInitDialogMsg is a message that is sent to show the init dialog.
-type ShowInitDialogMsg struct {
-	Show bool
-}

internal/tui/components/dialog/models.go 🔗

@@ -1,373 +0,0 @@
-package dialog
-
-import (
-	"fmt"
-	"slices"
-	"strings"
-
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/models"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-const (
-	numVisibleModels = 10
-	maxDialogWidth   = 40
-)
-
-// ModelSelectedMsg is sent when a model is selected
-type ModelSelectedMsg struct {
-	Model models.Model
-}
-
-// CloseModelDialogMsg is sent when a model is selected
-type CloseModelDialogMsg struct{}
-
-// ModelDialog interface for the model selection dialog
-type ModelDialog interface {
-	tea.Model
-	layout.Bindings
-}
-
-type modelDialogCmp struct {
-	models             []models.Model
-	provider           models.ModelProvider
-	availableProviders []models.ModelProvider
-
-	selectedIdx     int
-	width           int
-	height          int
-	scrollOffset    int
-	hScrollOffset   int
-	hScrollPossible bool
-}
-
-type modelKeyMap struct {
-	Up     key.Binding
-	Down   key.Binding
-	Left   key.Binding
-	Right  key.Binding
-	Enter  key.Binding
-	Escape key.Binding
-	J      key.Binding
-	K      key.Binding
-	H      key.Binding
-	L      key.Binding
-}
-
-var modelKeys = modelKeyMap{
-	Up: key.NewBinding(
-		key.WithKeys("up"),
-		key.WithHelp("↑", "previous model"),
-	),
-	Down: key.NewBinding(
-		key.WithKeys("down"),
-		key.WithHelp("↓", "next model"),
-	),
-	Left: key.NewBinding(
-		key.WithKeys("left"),
-		key.WithHelp("←", "scroll left"),
-	),
-	Right: key.NewBinding(
-		key.WithKeys("right"),
-		key.WithHelp("→", "scroll right"),
-	),
-	Enter: key.NewBinding(
-		key.WithKeys("enter"),
-		key.WithHelp("enter", "select model"),
-	),
-	Escape: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "close"),
-	),
-	J: key.NewBinding(
-		key.WithKeys("j"),
-		key.WithHelp("j", "next model"),
-	),
-	K: key.NewBinding(
-		key.WithKeys("k"),
-		key.WithHelp("k", "previous model"),
-	),
-	H: key.NewBinding(
-		key.WithKeys("h"),
-		key.WithHelp("h", "scroll left"),
-	),
-	L: key.NewBinding(
-		key.WithKeys("l"),
-		key.WithHelp("l", "scroll right"),
-	),
-}
-
-func (m *modelDialogCmp) Init() tea.Cmd {
-	m.setupModels()
-	return nil
-}
-
-func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
-			m.moveSelectionUp()
-		case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
-			m.moveSelectionDown()
-		case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
-			if m.hScrollPossible {
-				m.switchProvider(-1)
-			}
-		case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
-			if m.hScrollPossible {
-				m.switchProvider(1)
-			}
-		case key.Matches(msg, modelKeys.Enter):
-			util.ReportInfo(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name))
-			return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
-		case key.Matches(msg, modelKeys.Escape):
-			return m, util.CmdHandler(CloseModelDialogMsg{})
-		}
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.height = msg.Height
-	}
-
-	return m, nil
-}
-
-// moveSelectionUp moves the selection up or wraps to bottom
-func (m *modelDialogCmp) moveSelectionUp() {
-	if m.selectedIdx > 0 {
-		m.selectedIdx--
-	} else {
-		m.selectedIdx = len(m.models) - 1
-		m.scrollOffset = max(0, len(m.models)-numVisibleModels)
-	}
-
-	// Keep selection visible
-	if m.selectedIdx < m.scrollOffset {
-		m.scrollOffset = m.selectedIdx
-	}
-}
-
-// moveSelectionDown moves the selection down or wraps to top
-func (m *modelDialogCmp) moveSelectionDown() {
-	if m.selectedIdx < len(m.models)-1 {
-		m.selectedIdx++
-	} else {
-		m.selectedIdx = 0
-		m.scrollOffset = 0
-	}
-
-	// Keep selection visible
-	if m.selectedIdx >= m.scrollOffset+numVisibleModels {
-		m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
-	}
-}
-
-func (m *modelDialogCmp) switchProvider(offset int) {
-	newOffset := m.hScrollOffset + offset
-
-	// Ensure we stay within bounds
-	if newOffset < 0 {
-		newOffset = len(m.availableProviders) - 1
-	}
-	if newOffset >= len(m.availableProviders) {
-		newOffset = 0
-	}
-
-	m.hScrollOffset = newOffset
-	m.provider = m.availableProviders[m.hScrollOffset]
-	m.setupModelsForProvider(m.provider)
-}
-
-func (m *modelDialogCmp) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	// Capitalize first letter of provider name
-	providerName := strings.ToUpper(string(m.provider)[:1]) + string(m.provider[1:])
-	title := baseStyle.
-		Foreground(t.Primary()).
-		Bold(true).
-		Width(maxDialogWidth).
-		Padding(0, 0, 1).
-		Render(fmt.Sprintf("Select %s Model", providerName))
-
-	// Render visible models
-	endIdx := min(m.scrollOffset+numVisibleModels, len(m.models))
-	modelItems := make([]string, 0, endIdx-m.scrollOffset)
-
-	for i := m.scrollOffset; i < endIdx; i++ {
-		itemStyle := baseStyle.Width(maxDialogWidth)
-		if i == m.selectedIdx {
-			itemStyle = itemStyle.Background(t.Primary()).
-				Foreground(t.Background()).Bold(true)
-		}
-		modelItems = append(modelItems, itemStyle.Render(m.models[i].Name))
-	}
-
-	scrollIndicator := m.getScrollIndicators(maxDialogWidth)
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		title,
-		baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
-		scrollIndicator,
-	)
-
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
-}
-
-func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
-	var indicator string
-
-	if len(m.models) > numVisibleModels {
-		if m.scrollOffset > 0 {
-			indicator += "↑ "
-		}
-		if m.scrollOffset+numVisibleModels < len(m.models) {
-			indicator += "↓ "
-		}
-	}
-
-	if m.hScrollPossible {
-		if m.hScrollOffset > 0 {
-			indicator = "← " + indicator
-		}
-		if m.hScrollOffset < len(m.availableProviders)-1 {
-			indicator += "→"
-		}
-	}
-
-	if indicator == "" {
-		return ""
-	}
-
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	return baseStyle.
-		Foreground(t.Primary()).
-		Width(maxWidth).
-		Align(lipgloss.Right).
-		Bold(true).
-		Render(indicator)
-}
-
-func (m *modelDialogCmp) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(modelKeys)
-}
-
-func (m *modelDialogCmp) setupModels() {
-	cfg := config.Get()
-	modelInfo := GetSelectedModel(cfg)
-	m.availableProviders = getEnabledProviders(cfg)
-	m.hScrollPossible = len(m.availableProviders) > 1
-
-	m.provider = modelInfo.Provider
-	m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
-
-	m.setupModelsForProvider(m.provider)
-}
-
-func GetSelectedModel(cfg *config.Config) models.Model {
-
-	agentCfg := cfg.Agents[config.AgentCoder]
-	selectedModelId := agentCfg.Model
-	return models.SupportedModels[selectedModelId]
-}
-
-func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
-	var providers []models.ModelProvider
-	for providerId, provider := range cfg.Providers {
-		if !provider.Disabled {
-			providers = append(providers, providerId)
-		}
-	}
-
-	// Sort by provider popularity
-	slices.SortFunc(providers, func(a, b models.ModelProvider) int {
-		rA := models.ProviderPopularity[a]
-		rB := models.ProviderPopularity[b]
-
-		// models not included in popularity ranking default to last
-		if rA == 0 {
-			rA = 999
-		}
-		if rB == 0 {
-			rB = 999
-		}
-		return rA - rB
-	})
-	return providers
-}
-
-// findProviderIndex returns the index of the provider in the list, or -1 if not found
-func findProviderIndex(providers []models.ModelProvider, provider models.ModelProvider) int {
-	for i, p := range providers {
-		if p == provider {
-			return i
-		}
-	}
-	return -1
-}
-
-func (m *modelDialogCmp) setupModelsForProvider(provider models.ModelProvider) {
-	cfg := config.Get()
-	agentCfg := cfg.Agents[config.AgentCoder]
-	selectedModelId := agentCfg.Model
-
-	m.provider = provider
-	m.models = getModelsForProvider(provider)
-	m.selectedIdx = 0
-	m.scrollOffset = 0
-
-	// Try to select the current model if it belongs to this provider
-	if provider == models.SupportedModels[selectedModelId].Provider {
-		for i, model := range m.models {
-			if model.ID == selectedModelId {
-				m.selectedIdx = i
-				// Adjust scroll position to keep selected model visible
-				if m.selectedIdx >= numVisibleModels {
-					m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
-				}
-				break
-			}
-		}
-	}
-}
-
-func getModelsForProvider(provider models.ModelProvider) []models.Model {
-	var providerModels []models.Model
-	for _, model := range models.SupportedModels {
-		if model.Provider == provider {
-			providerModels = append(providerModels, model)
-		}
-	}
-
-	// reverse alphabetical order (if llm naming was consistent latest would appear first)
-	slices.SortFunc(providerModels, func(a, b models.Model) int {
-		if a.Name > b.Name {
-			return -1
-		} else if a.Name < b.Name {
-			return 1
-		}
-		return 0
-	})
-
-	return providerModels
-}
-
-func NewModelDialogCmp() ModelDialog {
-	return &modelDialogCmp{}
-}

internal/tui/components/dialog/permission.go 🔗

@@ -1,522 +0,0 @@
-package dialog
-
-import (
-	"fmt"
-	"strings"
-
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/diff"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
-	"github.com/opencode-ai/opencode/internal/permission"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type PermissionAction string
-
-// Permission responses
-const (
-	PermissionAllow           PermissionAction = "allow"
-	PermissionAllowForSession PermissionAction = "allow_session"
-	PermissionDeny            PermissionAction = "deny"
-)
-
-// PermissionResponseMsg represents the user's response to a permission request
-type PermissionResponseMsg struct {
-	Permission permission.PermissionRequest
-	Action     PermissionAction
-}
-
-// PermissionDialogCmp interface for permission dialog component
-type PermissionDialogCmp interface {
-	tea.Model
-	layout.Bindings
-	SetPermissions(permission permission.PermissionRequest) tea.Cmd
-}
-
-type permissionsMapping struct {
-	Left         key.Binding
-	Right        key.Binding
-	EnterSpace   key.Binding
-	Allow        key.Binding
-	AllowSession key.Binding
-	Deny         key.Binding
-	Tab          key.Binding
-}
-
-var permissionsKeys = permissionsMapping{
-	Left: key.NewBinding(
-		key.WithKeys("left"),
-		key.WithHelp("←", "switch options"),
-	),
-	Right: key.NewBinding(
-		key.WithKeys("right"),
-		key.WithHelp("→", "switch options"),
-	),
-	EnterSpace: key.NewBinding(
-		key.WithKeys("enter", " "),
-		key.WithHelp("enter/space", "confirm"),
-	),
-	Allow: key.NewBinding(
-		key.WithKeys("a"),
-		key.WithHelp("a", "allow"),
-	),
-	AllowSession: key.NewBinding(
-		key.WithKeys("s"),
-		key.WithHelp("s", "allow for session"),
-	),
-	Deny: key.NewBinding(
-		key.WithKeys("d"),
-		key.WithHelp("d", "deny"),
-	),
-	Tab: key.NewBinding(
-		key.WithKeys("tab"),
-		key.WithHelp("tab", "switch options"),
-	),
-}
-
-// permissionDialogCmp is the implementation of PermissionDialog
-type permissionDialogCmp struct {
-	width           int
-	height          int
-	permission      permission.PermissionRequest
-	windowSize      tea.WindowSizeMsg
-	contentViewPort viewport.Model
-	selectedOption  int // 0: Allow, 1: Allow for session, 2: Deny
-
-	diffCache     map[string]string
-	markdownCache map[string]string
-}
-
-func (p *permissionDialogCmp) Init() tea.Cmd {
-	return p.contentViewPort.Init()
-}
-
-func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		p.windowSize = msg
-		cmd := p.SetSize()
-		cmds = append(cmds, cmd)
-		p.markdownCache = make(map[string]string)
-		p.diffCache = make(map[string]string)
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
-			p.selectedOption = (p.selectedOption + 1) % 3
-			return p, nil
-		case key.Matches(msg, permissionsKeys.Left):
-			p.selectedOption = (p.selectedOption + 2) % 3
-		case key.Matches(msg, permissionsKeys.EnterSpace):
-			return p, p.selectCurrentOption()
-		case key.Matches(msg, permissionsKeys.Allow):
-			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
-		case key.Matches(msg, permissionsKeys.AllowSession):
-			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
-		case key.Matches(msg, permissionsKeys.Deny):
-			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
-		default:
-			// Pass other keys to viewport
-			viewPort, cmd := p.contentViewPort.Update(msg)
-			p.contentViewPort = viewPort
-			cmds = append(cmds, cmd)
-		}
-	}
-
-	return p, tea.Batch(cmds...)
-}
-
-func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
-	var action PermissionAction
-
-	switch p.selectedOption {
-	case 0:
-		action = PermissionAllow
-	case 1:
-		action = PermissionAllowForSession
-	case 2:
-		action = PermissionDeny
-	}
-
-	return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
-}
-
-func (p *permissionDialogCmp) renderButtons() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	allowStyle := baseStyle
-	allowSessionStyle := baseStyle
-	denyStyle := baseStyle
-	spacerStyle := baseStyle.Background(t.Background())
-
-	// Style the selected button
-	switch p.selectedOption {
-	case 0:
-		allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
-		allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
-		denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
-	case 1:
-		allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
-		allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
-		denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
-	case 2:
-		allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
-		allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
-		denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
-	}
-
-	allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
-	allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
-	denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
-
-	content := lipgloss.JoinHorizontal(
-		lipgloss.Left,
-		allowButton,
-		spacerStyle.Render("  "),
-		allowSessionButton,
-		spacerStyle.Render("  "),
-		denyButton,
-		spacerStyle.Render("  "),
-	)
-
-	remainingWidth := p.width - lipgloss.Width(content)
-	if remainingWidth > 0 {
-		content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
-	}
-	return content
-}
-
-func (p *permissionDialogCmp) renderHeader() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
-	toolValue := baseStyle.
-		Foreground(t.Text()).
-		Width(p.width - lipgloss.Width(toolKey)).
-		Render(fmt.Sprintf(": %s", p.permission.ToolName))
-
-	pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
-	pathValue := baseStyle.
-		Foreground(t.Text()).
-		Width(p.width - lipgloss.Width(pathKey)).
-		Render(fmt.Sprintf(": %s", p.permission.Path))
-
-	headerParts := []string{
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			toolKey,
-			toolValue,
-		),
-		baseStyle.Render(strings.Repeat(" ", p.width)),
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			pathKey,
-			pathValue,
-		),
-		baseStyle.Render(strings.Repeat(" ", p.width)),
-	}
-
-	// Add tool-specific header information
-	switch p.permission.ToolName {
-	case tools.BashToolName:
-		headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
-	case tools.EditToolName:
-		params := p.permission.Params.(tools.EditPermissionsParams)
-		fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
-		filePath := baseStyle.
-			Foreground(t.Text()).
-			Width(p.width - lipgloss.Width(fileKey)).
-			Render(fmt.Sprintf(": %s", params.FilePath))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				fileKey,
-				filePath,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-
-	case tools.WriteToolName:
-		params := p.permission.Params.(tools.WritePermissionsParams)
-		fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
-		filePath := baseStyle.
-			Foreground(t.Text()).
-			Width(p.width - lipgloss.Width(fileKey)).
-			Render(fmt.Sprintf(": %s", params.FilePath))
-		headerParts = append(headerParts,
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				fileKey,
-				filePath,
-			),
-			baseStyle.Render(strings.Repeat(" ", p.width)),
-		)
-	case tools.FetchToolName:
-		headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
-	}
-
-	return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-}
-
-func (p *permissionDialogCmp) renderBashContent() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
-		content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
-
-		// Use the cache for markdown rendering
-		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
-			r := styles.GetMarkdownRenderer(p.width - 10)
-			s, err := r.Render(content)
-			return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
-		})
-
-		finalContent := baseStyle.
-			Width(p.contentViewPort.Width).
-			Render(renderedContent)
-		p.contentViewPort.SetContent(finalContent)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderEditContent() string {
-	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
-		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
-			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
-		})
-
-		p.contentViewPort.SetContent(diff)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderPatchContent() string {
-	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
-		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
-			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
-		})
-
-		p.contentViewPort.SetContent(diff)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderWriteContent() string {
-	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
-		// Use the cache for diff rendering
-		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
-			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
-		})
-
-		p.contentViewPort.SetContent(diff)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderFetchContent() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
-		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
-
-		// Use the cache for markdown rendering
-		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
-			r := styles.GetMarkdownRenderer(p.width - 10)
-			s, err := r.Render(content)
-			return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
-		})
-
-		finalContent := baseStyle.
-			Width(p.contentViewPort.Width).
-			Render(renderedContent)
-		p.contentViewPort.SetContent(finalContent)
-		return p.styleViewport()
-	}
-	return ""
-}
-
-func (p *permissionDialogCmp) renderDefaultContent() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	content := p.permission.Description
-
-	// Use the cache for markdown rendering
-	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
-		r := styles.GetMarkdownRenderer(p.width - 10)
-		s, err := r.Render(content)
-		return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
-	})
-
-	finalContent := baseStyle.
-		Width(p.contentViewPort.Width).
-		Render(renderedContent)
-	p.contentViewPort.SetContent(finalContent)
-
-	if renderedContent == "" {
-		return ""
-	}
-
-	return p.styleViewport()
-}
-
-func (p *permissionDialogCmp) styleViewport() string {
-	t := theme.CurrentTheme()
-	contentStyle := lipgloss.NewStyle().
-		Background(t.Background())
-
-	return contentStyle.Render(p.contentViewPort.View())
-}
-
-func (p *permissionDialogCmp) render() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	title := baseStyle.
-		Bold(true).
-		Width(p.width - 4).
-		Foreground(t.Primary()).
-		Render("Permission Required")
-	// Render header
-	headerContent := p.renderHeader()
-	// Render buttons
-	buttons := p.renderButtons()
-
-	// Calculate content height dynamically based on window size
-	p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
-	p.contentViewPort.Width = p.width - 4
-
-	// Render content based on tool type
-	var contentFinal string
-	switch p.permission.ToolName {
-	case tools.BashToolName:
-		contentFinal = p.renderBashContent()
-	case tools.EditToolName:
-		contentFinal = p.renderEditContent()
-	case tools.PatchToolName:
-		contentFinal = p.renderPatchContent()
-	case tools.WriteToolName:
-		contentFinal = p.renderWriteContent()
-	case tools.FetchToolName:
-		contentFinal = p.renderFetchContent()
-	default:
-		contentFinal = p.renderDefaultContent()
-	}
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Top,
-		title,
-		baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
-		headerContent,
-		contentFinal,
-		buttons,
-		baseStyle.Render(strings.Repeat(" ", p.width-4)),
-	)
-
-	return baseStyle.
-		Padding(1, 0, 0, 1).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(p.width).
-		Height(p.height).
-		Render(
-			content,
-		)
-}
-
-func (p *permissionDialogCmp) View() string {
-	return p.render()
-}
-
-func (p *permissionDialogCmp) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(permissionsKeys)
-}
-
-func (p *permissionDialogCmp) SetSize() tea.Cmd {
-	if p.permission.ID == "" {
-		return nil
-	}
-	switch p.permission.ToolName {
-	case tools.BashToolName:
-		p.width = int(float64(p.windowSize.Width) * 0.4)
-		p.height = int(float64(p.windowSize.Height) * 0.3)
-	case tools.EditToolName:
-		p.width = int(float64(p.windowSize.Width) * 0.8)
-		p.height = int(float64(p.windowSize.Height) * 0.8)
-	case tools.WriteToolName:
-		p.width = int(float64(p.windowSize.Width) * 0.8)
-		p.height = int(float64(p.windowSize.Height) * 0.8)
-	case tools.FetchToolName:
-		p.width = int(float64(p.windowSize.Width) * 0.4)
-		p.height = int(float64(p.windowSize.Height) * 0.3)
-	default:
-		p.width = int(float64(p.windowSize.Width) * 0.7)
-		p.height = int(float64(p.windowSize.Height) * 0.5)
-	}
-	return nil
-}
-
-func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
-	p.permission = permission
-	return p.SetSize()
-}
-
-// Helper to get or set cached diff content
-func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
-	if cached, ok := c.diffCache[key]; ok {
-		return cached
-	}
-
-	content, err := generator()
-	if err != nil {
-		return fmt.Sprintf("Error formatting diff: %v", err)
-	}
-
-	c.diffCache[key] = content
-
-	return content
-}
-
-// Helper to get or set cached markdown content
-func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
-	if cached, ok := c.markdownCache[key]; ok {
-		return cached
-	}
-
-	content, err := generator()
-	if err != nil {
-		return fmt.Sprintf("Error rendering markdown: %v", err)
-	}
-
-	c.markdownCache[key] = content
-
-	return content
-}
-
-func NewPermissionDialogCmp() PermissionDialogCmp {
-	// Create viewport for content
-	contentViewport := viewport.New(0, 0)
-
-	return &permissionDialogCmp{
-		contentViewPort: contentViewport,
-		selectedOption:  0, // Default to "Allow"
-		diffCache:       make(map[string]string),
-		markdownCache:   make(map[string]string),
-	}
-}

internal/tui/components/dialog/quit.go 🔗

@@ -1,136 +0,0 @@
-package dialog
-
-import (
-	"strings"
-
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-const question = "Are you sure you want to quit?"
-
-type CloseQuitMsg struct{}
-
-type QuitDialog interface {
-	tea.Model
-	layout.Bindings
-}
-
-type quitDialogCmp struct {
-	selectedNo bool
-}
-
-type helpMapping struct {
-	LeftRight  key.Binding
-	EnterSpace key.Binding
-	Yes        key.Binding
-	No         key.Binding
-	Tab        key.Binding
-}
-
-var helpKeys = helpMapping{
-	LeftRight: key.NewBinding(
-		key.WithKeys("left", "right"),
-		key.WithHelp("←/→", "switch options"),
-	),
-	EnterSpace: key.NewBinding(
-		key.WithKeys("enter", " "),
-		key.WithHelp("enter/space", "confirm"),
-	),
-	Yes: key.NewBinding(
-		key.WithKeys("y", "Y"),
-		key.WithHelp("y/Y", "yes"),
-	),
-	No: key.NewBinding(
-		key.WithKeys("n", "N"),
-		key.WithHelp("n/N", "no"),
-	),
-	Tab: key.NewBinding(
-		key.WithKeys("tab"),
-		key.WithHelp("tab", "switch options"),
-	),
-}
-
-func (q *quitDialogCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
-			q.selectedNo = !q.selectedNo
-			return q, nil
-		case key.Matches(msg, helpKeys.EnterSpace):
-			if !q.selectedNo {
-				return q, tea.Quit
-			}
-			return q, util.CmdHandler(CloseQuitMsg{})
-		case key.Matches(msg, helpKeys.Yes):
-			return q, tea.Quit
-		case key.Matches(msg, helpKeys.No):
-			return q, util.CmdHandler(CloseQuitMsg{})
-		}
-	}
-	return q, nil
-}
-
-func (q *quitDialogCmp) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-	
-	yesStyle := baseStyle
-	noStyle := baseStyle
-	spacerStyle := baseStyle.Background(t.Background())
-
-	if q.selectedNo {
-		noStyle = noStyle.Background(t.Primary()).Foreground(t.Background())
-		yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary())
-	} else {
-		yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background())
-		noStyle = noStyle.Background(t.Background()).Foreground(t.Primary())
-	}
-
-	yesButton := yesStyle.Padding(0, 1).Render("Yes")
-	noButton := noStyle.Padding(0, 1).Render("No")
-
-	buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render("  "), noButton)
-
-	width := lipgloss.Width(question)
-	remainingWidth := width - lipgloss.Width(buttons)
-	if remainingWidth > 0 {
-		buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
-	}
-
-	content := baseStyle.Render(
-		lipgloss.JoinVertical(
-			lipgloss.Center,
-			question,
-			"",
-			buttons,
-		),
-	)
-
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
-}
-
-func (q *quitDialogCmp) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(helpKeys)
-}
-
-func NewQuitCmp() QuitDialog {
-	return &quitDialogCmp{
-		selectedNo: true,
-	}
-}

internal/tui/components/dialog/session.go 🔗

@@ -1,230 +0,0 @@
-package dialog
-
-import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-// SessionSelectedMsg is sent when a session is selected
-type SessionSelectedMsg struct {
-	Session session.Session
-}
-
-// CloseSessionDialogMsg is sent when the session dialog is closed
-type CloseSessionDialogMsg struct{}
-
-// SessionDialog interface for the session switching dialog
-type SessionDialog interface {
-	tea.Model
-	layout.Bindings
-	SetSessions(sessions []session.Session)
-	SetSelectedSession(sessionID string)
-}
-
-type sessionDialogCmp struct {
-	sessions          []session.Session
-	selectedIdx       int
-	width             int
-	height            int
-	selectedSessionID string
-}
-
-type sessionKeyMap struct {
-	Up     key.Binding
-	Down   key.Binding
-	Enter  key.Binding
-	Escape key.Binding
-	J      key.Binding
-	K      key.Binding
-}
-
-var sessionKeys = sessionKeyMap{
-	Up: key.NewBinding(
-		key.WithKeys("up"),
-		key.WithHelp("↑", "previous session"),
-	),
-	Down: key.NewBinding(
-		key.WithKeys("down"),
-		key.WithHelp("↓", "next session"),
-	),
-	Enter: key.NewBinding(
-		key.WithKeys("enter"),
-		key.WithHelp("enter", "select session"),
-	),
-	Escape: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "close"),
-	),
-	J: key.NewBinding(
-		key.WithKeys("j"),
-		key.WithHelp("j", "next session"),
-	),
-	K: key.NewBinding(
-		key.WithKeys("k"),
-		key.WithHelp("k", "previous session"),
-	),
-}
-
-func (s *sessionDialogCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
-			if s.selectedIdx > 0 {
-				s.selectedIdx--
-			}
-			return s, nil
-		case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
-			if s.selectedIdx < len(s.sessions)-1 {
-				s.selectedIdx++
-			}
-			return s, nil
-		case key.Matches(msg, sessionKeys.Enter):
-			if len(s.sessions) > 0 {
-				return s, util.CmdHandler(SessionSelectedMsg{
-					Session: s.sessions[s.selectedIdx],
-				})
-			}
-		case key.Matches(msg, sessionKeys.Escape):
-			return s, util.CmdHandler(CloseSessionDialogMsg{})
-		}
-	case tea.WindowSizeMsg:
-		s.width = msg.Width
-		s.height = msg.Height
-	}
-	return s, nil
-}
-
-func (s *sessionDialogCmp) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-	
-	if len(s.sessions) == 0 {
-		return baseStyle.Padding(1, 2).
-			Border(lipgloss.RoundedBorder()).
-			BorderBackground(t.Background()).
-			BorderForeground(t.TextMuted()).
-			Width(40).
-			Render("No sessions available")
-	}
-
-	// Calculate max width needed for session titles
-	maxWidth := 40 // Minimum width
-	for _, sess := range s.sessions {
-		if len(sess.Title) > maxWidth-4 { // Account for padding
-			maxWidth = len(sess.Title) + 4
-		}
-	}
-
-	maxWidth = max(30, min(maxWidth, s.width-15)) // Limit width to avoid overflow
-
-	// Limit height to avoid taking up too much screen space
-	maxVisibleSessions := min(10, len(s.sessions))
-
-	// Build the session list
-	sessionItems := make([]string, 0, maxVisibleSessions)
-	startIdx := 0
-
-	// If we have more sessions than can be displayed, adjust the start index
-	if len(s.sessions) > maxVisibleSessions {
-		// Center the selected item when possible
-		halfVisible := maxVisibleSessions / 2
-		if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
-			startIdx = s.selectedIdx - halfVisible
-		} else if s.selectedIdx >= len(s.sessions)-halfVisible {
-			startIdx = len(s.sessions) - maxVisibleSessions
-		}
-	}
-
-	endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
-
-	for i := startIdx; i < endIdx; i++ {
-		sess := s.sessions[i]
-		itemStyle := baseStyle.Width(maxWidth)
-
-		if i == s.selectedIdx {
-			itemStyle = itemStyle.
-				Background(t.Primary()).
-				Foreground(t.Background()).
-				Bold(true)
-		}
-
-		sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
-	}
-
-	title := baseStyle.
-		Foreground(t.Primary()).
-		Bold(true).
-		Width(maxWidth).
-		Padding(0, 1).
-		Render("Switch Session")
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		title,
-		baseStyle.Width(maxWidth).Render(""),
-		baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
-		baseStyle.Width(maxWidth).Render(""),
-	)
-
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(t.Background()).
-		BorderForeground(t.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
-}
-
-func (s *sessionDialogCmp) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(sessionKeys)
-}
-
-func (s *sessionDialogCmp) SetSessions(sessions []session.Session) {
-	s.sessions = sessions
-
-	// If we have a selected session ID, find its index
-	if s.selectedSessionID != "" {
-		for i, sess := range sessions {
-			if sess.ID == s.selectedSessionID {
-				s.selectedIdx = i
-				return
-			}
-		}
-	}
-
-	// Default to first session if selected not found
-	s.selectedIdx = 0
-}
-
-func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
-	s.selectedSessionID = sessionID
-
-	// Update the selected index if sessions are already loaded
-	if len(s.sessions) > 0 {
-		for i, sess := range s.sessions {
-			if sess.ID == sessionID {
-				s.selectedIdx = i
-				return
-			}
-		}
-	}
-}
-
-// NewSessionDialogCmp creates a new session switching dialog
-func NewSessionDialogCmp() SessionDialog {
-	return &sessionDialogCmp{
-		sessions:          []session.Session{},
-		selectedIdx:       0,
-		selectedSessionID: "",
-	}
-}

internal/tui/components/dialog/theme.go 🔗

@@ -1,198 +0,0 @@
-package dialog
-
-import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-// ThemeChangedMsg is sent when the theme is changed
-type ThemeChangedMsg struct {
-	ThemeName string
-}
-
-// CloseThemeDialogMsg is sent when the theme dialog is closed
-type CloseThemeDialogMsg struct{}
-
-// ThemeDialog interface for the theme switching dialog
-type ThemeDialog interface {
-	tea.Model
-	layout.Bindings
-}
-
-type themeDialogCmp struct {
-	themes       []string
-	selectedIdx  int
-	width        int
-	height       int
-	currentTheme string
-}
-
-type themeKeyMap struct {
-	Up     key.Binding
-	Down   key.Binding
-	Enter  key.Binding
-	Escape key.Binding
-	J      key.Binding
-	K      key.Binding
-}
-
-var themeKeys = themeKeyMap{
-	Up: key.NewBinding(
-		key.WithKeys("up"),
-		key.WithHelp("↑", "previous theme"),
-	),
-	Down: key.NewBinding(
-		key.WithKeys("down"),
-		key.WithHelp("↓", "next theme"),
-	),
-	Enter: key.NewBinding(
-		key.WithKeys("enter"),
-		key.WithHelp("enter", "select theme"),
-	),
-	Escape: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "close"),
-	),
-	J: key.NewBinding(
-		key.WithKeys("j"),
-		key.WithHelp("j", "next theme"),
-	),
-	K: key.NewBinding(
-		key.WithKeys("k"),
-		key.WithHelp("k", "previous theme"),
-	),
-}
-
-func (t *themeDialogCmp) Init() tea.Cmd {
-	// Load available themes and update selectedIdx based on current theme
-	t.themes = theme.AvailableThemes()
-	t.currentTheme = theme.CurrentThemeName()
-
-	// Find the current theme in the list
-	for i, name := range t.themes {
-		if name == t.currentTheme {
-			t.selectedIdx = i
-			break
-		}
-	}
-
-	return nil
-}
-
-func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
-			if t.selectedIdx > 0 {
-				t.selectedIdx--
-			}
-			return t, nil
-		case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J):
-			if t.selectedIdx < len(t.themes)-1 {
-				t.selectedIdx++
-			}
-			return t, nil
-		case key.Matches(msg, themeKeys.Enter):
-			if len(t.themes) > 0 {
-				previousTheme := theme.CurrentThemeName()
-				selectedTheme := t.themes[t.selectedIdx]
-				if previousTheme == selectedTheme {
-					return t, util.CmdHandler(CloseThemeDialogMsg{})
-				}
-				if err := theme.SetTheme(selectedTheme); err != nil {
-					return t, util.ReportError(err)
-				}
-				return t, util.CmdHandler(ThemeChangedMsg{
-					ThemeName: selectedTheme,
-				})
-			}
-		case key.Matches(msg, themeKeys.Escape):
-			return t, util.CmdHandler(CloseThemeDialogMsg{})
-		}
-	case tea.WindowSizeMsg:
-		t.width = msg.Width
-		t.height = msg.Height
-	}
-	return t, nil
-}
-
-func (t *themeDialogCmp) View() string {
-	currentTheme := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	if len(t.themes) == 0 {
-		return baseStyle.Padding(1, 2).
-			Border(lipgloss.RoundedBorder()).
-			BorderBackground(currentTheme.Background()).
-			BorderForeground(currentTheme.TextMuted()).
-			Width(40).
-			Render("No themes available")
-	}
-
-	// Calculate max width needed for theme names
-	maxWidth := 40 // Minimum width
-	for _, themeName := range t.themes {
-		if len(themeName) > maxWidth-4 { // Account for padding
-			maxWidth = len(themeName) + 4
-		}
-	}
-
-	maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow
-
-	// Build the theme list
-	themeItems := make([]string, 0, len(t.themes))
-	for i, themeName := range t.themes {
-		itemStyle := baseStyle.Width(maxWidth)
-
-		if i == t.selectedIdx {
-			itemStyle = itemStyle.
-				Background(currentTheme.Primary()).
-				Foreground(currentTheme.Background()).
-				Bold(true)
-		}
-
-		themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName))
-	}
-
-	title := baseStyle.
-		Foreground(currentTheme.Primary()).
-		Bold(true).
-		Width(maxWidth).
-		Padding(0, 1).
-		Render("Select Theme")
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		title,
-		baseStyle.Width(maxWidth).Render(""),
-		baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)),
-		baseStyle.Width(maxWidth).Render(""),
-	)
-
-	return baseStyle.Padding(1, 2).
-		Border(lipgloss.RoundedBorder()).
-		BorderBackground(currentTheme.Background()).
-		BorderForeground(currentTheme.TextMuted()).
-		Width(lipgloss.Width(content) + 4).
-		Render(content)
-}
-
-func (t *themeDialogCmp) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(themeKeys)
-}
-
-// NewThemeDialogCmp creates a new theme switching dialog
-func NewThemeDialogCmp() ThemeDialog {
-	return &themeDialogCmp{
-		themes:       []string{},
-		selectedIdx:  0,
-		currentTheme: "",
-	}
-}
-

internal/tui/components/dialogs/commands/arguments.go 🔗

@@ -0,0 +1,234 @@
+package commands
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/textinput"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+const (
+	argumentsDialogID dialogs.DialogID = "arguments"
+)
+
+// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
+type ShowArgumentsDialogMsg struct {
+	CommandID string
+	Content   string
+	ArgNames  []string
+}
+
+// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
+type CloseArgumentsDialogMsg struct {
+	Submit    bool
+	CommandID string
+	Content   string
+	Args      map[string]string
+}
+
+// CommandArgumentsDialog represents the commands dialog.
+type CommandArgumentsDialog interface {
+	dialogs.DialogModel
+}
+
+type commandArgumentsDialogCmp struct {
+	width   int
+	wWidth  int // Width of the terminal window
+	wHeight int // Height of the terminal window
+
+	inputs     []textinput.Model
+	focusIndex int
+	keys       ArgumentsDialogKeyMap
+	commandID  string
+	content    string
+	argNames   []string
+	help       help.Model
+}
+
+func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog {
+	t := styles.CurrentTheme()
+	inputs := make([]textinput.Model, len(argNames))
+
+	for i, name := range argNames {
+		ti := textinput.New()
+		ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
+		ti.SetWidth(40)
+		ti.SetVirtualCursor(false)
+		ti.Prompt = ""
+
+		ti.SetStyles(t.S().TextInput)
+		// Only focus the first input initially
+		if i == 0 {
+			ti.Focus()
+		} else {
+			ti.Blur()
+		}
+
+		inputs[i] = ti
+	}
+
+	return &commandArgumentsDialogCmp{
+		inputs:     inputs,
+		keys:       DefaultArgumentsDialogKeyMap(),
+		commandID:  commandID,
+		content:    content,
+		argNames:   argNames,
+		focusIndex: 0,
+		width:      60,
+		help:       help.New(),
+	}
+}
+
+// Init implements CommandArgumentsDialog.
+func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements CommandArgumentsDialog.
+func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		c.wWidth = msg.Width
+		c.wHeight = msg.Height
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, c.keys.Confirm):
+			if c.focusIndex == len(c.inputs)-1 {
+				content := c.content
+				for i, name := range c.argNames {
+					value := c.inputs[i].Value()
+					placeholder := "$" + name
+					content = strings.ReplaceAll(content, placeholder, value)
+				}
+				return c, tea.Sequence(
+					util.CmdHandler(dialogs.CloseDialogMsg{}),
+					util.CmdHandler(CommandRunCustomMsg{
+						Content: content,
+					}),
+				)
+			}
+			// Otherwise, move to the next input
+			c.inputs[c.focusIndex].Blur()
+			c.focusIndex++
+			c.inputs[c.focusIndex].Focus()
+		case key.Matches(msg, c.keys.Next):
+			// Move to the next input
+			c.inputs[c.focusIndex].Blur()
+			c.focusIndex = (c.focusIndex + 1) % len(c.inputs)
+			c.inputs[c.focusIndex].Focus()
+		case key.Matches(msg, c.keys.Previous):
+			// Move to the previous input
+			c.inputs[c.focusIndex].Blur()
+			c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
+			c.inputs[c.focusIndex].Focus()
+
+		default:
+			var cmd tea.Cmd
+			c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
+			return c, cmd
+		}
+	}
+	return c, nil
+}
+
+// View implements CommandArgumentsDialog.
+func (c *commandArgumentsDialogCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	title := lipgloss.NewStyle().
+		Foreground(t.Primary).
+		Bold(true).
+		Padding(0, 1).
+		Render("Command Arguments")
+
+	explanation := t.S().Text.
+		Padding(0, 1).
+		Render("This command requires arguments.")
+
+	// Create input fields for each argument
+	inputFields := make([]string, len(c.inputs))
+	for i, input := range c.inputs {
+		// Highlight the label of the focused input
+		labelStyle := baseStyle.
+			Padding(1, 1, 0, 1)
+
+		if i == c.focusIndex {
+			labelStyle = labelStyle.Foreground(t.FgBase).Bold(true)
+		} else {
+			labelStyle = labelStyle.Foreground(t.FgMuted)
+		}
+
+		label := labelStyle.Render(c.argNames[i] + ":")
+
+		field := t.S().Text.
+			Padding(0, 1).
+			Render(input.View())
+
+		inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
+	}
+
+	// Join all elements vertically
+	elements := []string{title, explanation}
+	elements = append(elements, inputFields...)
+
+	c.help.ShowAll = false
+	helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
+	elements = append(elements, "", helpText)
+
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		elements...,
+	)
+
+	view := tea.NewView(
+		baseStyle.Padding(1, 1, 0, 1).
+			Border(lipgloss.RoundedBorder()).
+			BorderForeground(t.BorderFocus).
+			Width(c.width).
+			Render(content),
+	)
+	cursor := c.inputs[c.focusIndex].Cursor()
+	if cursor != nil {
+		cursor = c.moveCursor(cursor)
+	}
+	view.SetCursor(cursor)
+	return view
+}
+
+func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
+	row, col := c.Position()
+	offset := row + 3 + (1+c.focusIndex)*3
+	cursor.Y += offset
+	cursor.X = cursor.X + col + 3
+	return cursor
+}
+
+func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+	return t.S().Base.
+		Width(c.width).
+		Padding(1).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+}
+
+func (c *commandArgumentsDialogCmp) Position() (int, int) {
+	row := c.wHeight / 2
+	row -= c.wHeight / 2
+	col := c.wWidth / 2
+	col -= c.width / 2
+	return row, col
+}
+
+// ID implements CommandArgumentsDialog.
+func (c *commandArgumentsDialogCmp) ID() dialogs.DialogID {
+	return argumentsDialogID
+}

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -0,0 +1,287 @@
+package commands
+
+import (
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/tui/components/chat"
+	"github.com/charmbracelet/crush/internal/tui/components/completions"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+const (
+	CommandsDialogID dialogs.DialogID = "commands"
+
+	defaultWidth int = 70
+)
+
+const (
+	SystemCommands int = iota
+	UserCommands
+)
+
+// Command represents a command that can be executed
+type Command struct {
+	ID          string
+	Title       string
+	Description string
+	Shortcut    string // Optional shortcut for the command
+	Handler     func(cmd Command) tea.Cmd
+}
+
+// CommandsDialog represents the commands dialog.
+type CommandsDialog interface {
+	dialogs.DialogModel
+}
+
+type commandDialogCmp struct {
+	width   int
+	wWidth  int // Width of the terminal window
+	wHeight int // Height of the terminal window
+
+	commandList  list.ListModel
+	keyMap       CommandsDialogKeyMap
+	help         help.Model
+	commandType  int       // SystemCommands or UserCommands
+	userCommands []Command // User-defined commands
+	sessionID    string    // Current session ID
+}
+
+type (
+	SwitchSessionsMsg struct{}
+	SwitchModelMsg    struct{}
+	CompactMsg        struct {
+		SessionID string
+	}
+)
+
+func NewCommandDialog(sessionID string) CommandsDialog {
+	listKeyMap := list.DefaultKeyMap()
+	keyMap := DefaultCommandsDialogKeyMap()
+
+	listKeyMap.Down.SetEnabled(false)
+	listKeyMap.Up.SetEnabled(false)
+	listKeyMap.NDown.SetEnabled(false)
+	listKeyMap.NUp.SetEnabled(false)
+	listKeyMap.HalfPageDown.SetEnabled(false)
+	listKeyMap.HalfPageUp.SetEnabled(false)
+	listKeyMap.Home.SetEnabled(false)
+	listKeyMap.End.SetEnabled(false)
+
+	listKeyMap.DownOneItem = keyMap.Next
+	listKeyMap.UpOneItem = keyMap.Previous
+
+	t := styles.CurrentTheme()
+	commandList := list.New(
+		list.WithFilterable(true),
+		list.WithKeyMap(listKeyMap),
+		list.WithWrapNavigation(true),
+	)
+	help := help.New()
+	help.Styles = t.S().Help
+	return &commandDialogCmp{
+		commandList: commandList,
+		width:       defaultWidth,
+		keyMap:      DefaultCommandsDialogKeyMap(),
+		help:        help,
+		commandType: SystemCommands,
+		sessionID:   sessionID,
+	}
+}
+
+func (c *commandDialogCmp) Init() tea.Cmd {
+	commands, err := LoadCustomCommands()
+	if err != nil {
+		return util.ReportError(err)
+	}
+
+	c.userCommands = commands
+	c.SetCommandType(c.commandType)
+	return c.commandList.Init()
+}
+
+func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		c.wWidth = msg.Width
+		c.wHeight = msg.Height
+		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, c.keyMap.Select):
+			selectedItemInx := c.commandList.SelectedIndex()
+			if selectedItemInx == list.NoSelection {
+				return c, nil // No item selected, do nothing
+			}
+			items := c.commandList.Items()
+			selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
+			return c, tea.Sequence(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				selectedItem.Handler(selectedItem),
+			)
+		case key.Matches(msg, c.keyMap.Tab):
+			// Toggle command type between System and User commands
+			if c.commandType == SystemCommands {
+				return c, c.SetCommandType(UserCommands)
+			} else {
+				return c, c.SetCommandType(SystemCommands)
+			}
+		case key.Matches(msg, c.keyMap.Close):
+			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
+		default:
+			u, cmd := c.commandList.Update(msg)
+			c.commandList = u.(list.ListModel)
+			return c, cmd
+		}
+	}
+	return c, nil
+}
+
+func (c *commandDialogCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	listView := c.commandList.View()
+	radio := c.commandTypeRadio()
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
+		listView.String(),
+		"",
+		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
+	)
+	v := tea.NewView(c.style().Render(content))
+	if listView.Cursor() != nil {
+		c := c.moveCursor(listView.Cursor())
+		v.SetCursor(c)
+	}
+	return v
+}
+
+func (c *commandDialogCmp) commandTypeRadio() string {
+	t := styles.CurrentTheme()
+	choices := []string{"System", "User"}
+	iconSelected := "◉"
+	iconUnselected := "○"
+	if c.commandType == SystemCommands {
+		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
+	}
+	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
+}
+
+func (c *commandDialogCmp) listWidth() int {
+	return defaultWidth - 2 // 4 for padding
+}
+
+func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
+	c.commandType = commandType
+
+	var commands []Command
+	if c.commandType == SystemCommands {
+		commands = c.defaultCommands()
+	} else {
+		commands = c.userCommands
+	}
+
+	commandItems := []util.Model{}
+	for _, cmd := range commands {
+		opts := []completions.CompletionOption{}
+		if cmd.Shortcut != "" {
+			opts = append(opts, completions.WithShortcut(cmd.Shortcut))
+		}
+		commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
+	}
+	return c.commandList.SetItems(commandItems)
+}
+
+func (c *commandDialogCmp) listHeight() int {
+	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
+	return min(listHeigh, c.wHeight/2)
+}
+
+func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
+	row, col := c.Position()
+	offset := row + 3
+	cursor.Y += offset
+	cursor.X = cursor.X + col + 2
+	return cursor
+}
+
+func (c *commandDialogCmp) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+	return t.S().Base.
+		Width(c.width).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+}
+
+func (c *commandDialogCmp) Position() (int, int) {
+	row := c.wHeight/4 - 2 // just a bit above the center
+	col := c.wWidth / 2
+	col -= c.width / 2
+	return row, col
+}
+
+func (c *commandDialogCmp) defaultCommands() []Command {
+	commands := []Command{
+		{
+			ID:          "init",
+			Title:       "Initialize Project",
+			Description: "Create/Update the Crush.md memory file",
+			Handler: func(cmd Command) tea.Cmd {
+				prompt := `Please analyze this codebase and create a Crush.md file containing:
+	1. Build/lint/test commands - especially for running a single test
+	2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
+
+	The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
+	If there's already a crush.md, improve it.
+	If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
+				return util.CmdHandler(chat.SendMsg{
+					Text: prompt,
+				})
+			},
+		},
+	}
+
+	// Only show compact command if there's an active session
+	if c.sessionID != "" {
+		commands = append(commands, Command{
+			ID:          "compact",
+			Title:       "Compact Session",
+			Description: "Summarize the current session and create a new one with the summary",
+			Handler: func(cmd Command) tea.Cmd {
+				return util.CmdHandler(CompactMsg{
+					SessionID: c.sessionID,
+				})
+			},
+		})
+	}
+
+	return append(commands, []Command{
+		{
+			ID:          "switch_session",
+			Title:       "Switch Session",
+			Description: "Switch to a different session",
+			Shortcut:    "ctrl+s",
+			Handler: func(cmd Command) tea.Cmd {
+				return util.CmdHandler(SwitchSessionsMsg{})
+			},
+		},
+		{
+			ID:          "switch_model",
+			Title:       "Switch Model",
+			Description: "Switch to a different model",
+			Handler: func(cmd Command) tea.Cmd {
+				return util.CmdHandler(SwitchModelMsg{})
+			},
+		},
+	}...)
+}
+
+func (c *commandDialogCmp) ID() dialogs.DialogID {
+	return CommandsDialogID
+}

internal/tui/components/dialogs/commands/item.go 🔗

@@ -0,0 +1,57 @@
+package commands
+
+import (
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/x/ansi"
+)
+
+type ItemSection interface {
+	util.Model
+	layout.Sizeable
+	list.SectionHeader
+}
+type itemSectionModel struct {
+	width     int
+	title     string
+	noPadding bool // No padding for the section header
+}
+
+func NewItemSection(title string) ItemSection {
+	return &itemSectionModel{
+		title: title,
+	}
+}
+
+func (m *itemSectionModel) Init() tea.Cmd {
+	return nil
+}
+
+func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *itemSectionModel) View() tea.View {
+	t := styles.CurrentTheme()
+	title := ansi.Truncate(m.title, m.width-2, "…")
+	style := t.S().Base.Padding(1, 1, 0, 1)
+	title = t.S().Muted.Render(title)
+	return tea.NewView(style.Render(core.Section(title, m.width-2)))
+}
+
+func (m *itemSectionModel) GetSize() (int, int) {
+	return m.width, 1
+}
+
+func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	return nil
+}
+
+func (m *itemSectionModel) IsSectionHeader() bool {
+	return true
+}

internal/tui/components/dialogs/commands/keys.go 🔗

@@ -0,0 +1,107 @@
+package commands
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type CommandsDialogKeyMap struct {
+	Select,
+	Next,
+	Previous,
+	Tab,
+	Close key.Binding
+}
+
+func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
+	return CommandsDialogKeyMap{
+		Select: key.NewBinding(
+			key.WithKeys("enter", "ctrl+y"),
+			key.WithHelp("enter", "confirm"),
+		),
+		Next: key.NewBinding(
+			key.WithKeys("down", "ctrl+n"),
+			key.WithHelp("↓", "next item"),
+		),
+		Previous: key.NewBinding(
+			key.WithKeys("up", "ctrl+p"),
+			key.WithHelp("↑", "previous item"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "switch selection"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Tab,
+		key.NewBinding(
+			key.WithKeys("down", "up"),
+			key.WithHelp("↑↓", "choose"),
+		),
+		k.Select,
+		k.Close,
+	}
+}
+
+type ArgumentsDialogKeyMap struct {
+	Confirm  key.Binding
+	Next     key.Binding
+	Previous key.Binding
+}
+
+func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
+	return ArgumentsDialogKeyMap{
+		Confirm: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "confirm"),
+		),
+
+		Next: key.NewBinding(
+			key.WithKeys("tab", "down"),
+			key.WithHelp("tab/↓", "next"),
+		),
+		Previous: key.NewBinding(
+			key.WithKeys("shift+tab", "up"),
+			key.WithHelp("shift+tab/↑", "previous"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Confirm,
+		k.Next,
+		k.Previous,
+	}
+}

internal/tui/components/dialogs/commands/loader.go 🔗

@@ -0,0 +1,202 @@
+package commands
+
+import (
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+const (
+	UserCommandPrefix    = "user:"
+	ProjectCommandPrefix = "project:"
+)
+
+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
+
+type commandLoader struct {
+	sources []commandSource
+}
+
+type commandSource struct {
+	path   string
+	prefix string
+}
+
+func LoadCustomCommands() ([]Command, error) {
+	cfg := config.Get()
+	if cfg == nil {
+		return nil, fmt.Errorf("config not loaded")
+	}
+
+	loader := &commandLoader{
+		sources: buildCommandSources(cfg),
+	}
+
+	return loader.loadAll()
+}
+
+func buildCommandSources(cfg *config.Config) []commandSource {
+	var sources []commandSource
+
+	// XDG config directory
+	if dir := getXDGCommandsDir(); dir != "" {
+		sources = append(sources, commandSource{
+			path:   dir,
+			prefix: UserCommandPrefix,
+		})
+	}
+
+	// Home directory
+	if home, err := os.UserHomeDir(); err == nil {
+		sources = append(sources, commandSource{
+			path:   filepath.Join(home, ".crush", "commands"),
+			prefix: UserCommandPrefix,
+		})
+	}
+
+	// Project directory
+	sources = append(sources, commandSource{
+		path:   filepath.Join(cfg.Data.Directory, "commands"),
+		prefix: ProjectCommandPrefix,
+	})
+
+	return sources
+}
+
+func getXDGCommandsDir() string {
+	xdgHome := os.Getenv("XDG_CONFIG_HOME")
+	if xdgHome == "" {
+		if home, err := os.UserHomeDir(); err == nil {
+			xdgHome = filepath.Join(home, ".config")
+		}
+	}
+	if xdgHome != "" {
+		return filepath.Join(xdgHome, "crush", "commands")
+	}
+	return ""
+}
+
+func (l *commandLoader) loadAll() ([]Command, error) {
+	var commands []Command
+
+	for _, source := range l.sources {
+		if cmds, err := l.loadFromSource(source); err == nil {
+			commands = append(commands, cmds...)
+		}
+	}
+
+	return commands, nil
+}
+
+func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) {
+	if err := ensureDir(source.path); err != nil {
+		return nil, err
+	}
+
+	var commands []Command
+
+	err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
+		if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
+			return err
+		}
+
+		cmd, err := l.loadCommand(path, source.path, source.prefix)
+		if err != nil {
+			return nil // Skip invalid files
+		}
+
+		commands = append(commands, cmd)
+		return nil
+	})
+
+	return commands, err
+}
+
+func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) {
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return Command{}, err
+	}
+
+	id := buildCommandID(path, baseDir, prefix)
+
+	return Command{
+		ID:          id,
+		Title:       id,
+		Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)),
+		Handler:     createCommandHandler(id, string(content)),
+	}, nil
+}
+
+func buildCommandID(path, baseDir, prefix string) string {
+	relPath, _ := filepath.Rel(baseDir, path)
+	parts := strings.Split(relPath, string(filepath.Separator))
+
+	// Remove .md extension from last part
+	if len(parts) > 0 {
+		lastIdx := len(parts) - 1
+		parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
+	}
+
+	return prefix + strings.Join(parts, ":")
+}
+
+func createCommandHandler(id string, content string) func(Command) tea.Cmd {
+	return func(cmd Command) tea.Cmd {
+		args := extractArgNames(content)
+
+		if len(args) > 0 {
+			return util.CmdHandler(ShowArgumentsDialogMsg{
+				CommandID: id,
+				Content:   content,
+				ArgNames:  args,
+			})
+		}
+
+		return util.CmdHandler(CommandRunCustomMsg{
+			Content: content,
+		})
+	}
+}
+
+func extractArgNames(content string) []string {
+	matches := namedArgPattern.FindAllStringSubmatch(content, -1)
+	if len(matches) == 0 {
+		return nil
+	}
+
+	seen := make(map[string]bool)
+	var args []string
+
+	for _, match := range matches {
+		arg := match[1]
+		if !seen[arg] {
+			seen[arg] = true
+			args = append(args, arg)
+		}
+	}
+
+	return args
+}
+
+func ensureDir(path string) error {
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		return os.MkdirAll(path, 0o755)
+	}
+	return nil
+}
+
+func isMarkdownFile(name string) bool {
+	return strings.HasSuffix(strings.ToLower(name), ".md")
+}
+
+type CommandRunCustomMsg struct {
+	Content string
+}

internal/tui/components/dialogs/compact/compact.go 🔗

@@ -0,0 +1,265 @@
+package compact
+
+import (
+	"context"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+const CompactDialogID dialogs.DialogID = "compact"
+
+// CompactDialog interface for the session compact dialog
+type CompactDialog interface {
+	dialogs.DialogModel
+}
+
+type compactDialogCmp struct {
+	wWidth, wHeight int
+	width, height   int
+	selected        int
+	keyMap          KeyMap
+	sessionID       string
+	state           compactState
+	progress        string
+	agent           agent.Service
+	noAsk           bool // If true, skip confirmation dialog
+}
+
+type compactState int
+
+const (
+	stateConfirm compactState = iota
+	stateCompacting
+	stateError
+)
+
+// NewCompactDialogCmp creates a new session compact dialog
+func NewCompactDialogCmp(agent agent.Service, sessionID string, noAsk bool) CompactDialog {
+	return &compactDialogCmp{
+		sessionID: sessionID,
+		keyMap:    DefaultKeyMap(),
+		state:     stateConfirm,
+		selected:  0,
+		agent:     agent,
+		noAsk:     noAsk,
+	}
+}
+
+func (c *compactDialogCmp) Init() tea.Cmd {
+	if c.noAsk {
+		// If noAsk is true, skip confirmation and start compaction immediately
+		return c.startCompaction()
+	}
+	return nil
+}
+
+func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		c.wWidth = msg.Width
+		c.wHeight = msg.Height
+		cmd := c.SetSize()
+		return c, cmd
+
+	case tea.KeyPressMsg:
+		switch c.state {
+		case stateConfirm:
+			switch {
+			case key.Matches(msg, c.keyMap.ChangeSelection):
+				c.selected = (c.selected + 1) % 2
+				return c, nil
+			case key.Matches(msg, c.keyMap.Select):
+				if c.selected == 0 {
+					return c, c.startCompaction()
+				} else {
+					return c, util.CmdHandler(dialogs.CloseDialogMsg{})
+				}
+			case key.Matches(msg, c.keyMap.Y):
+				return c, c.startCompaction()
+			case key.Matches(msg, c.keyMap.N):
+				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
+			case key.Matches(msg, c.keyMap.Close):
+				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
+			}
+		case stateCompacting:
+			switch {
+			case key.Matches(msg, c.keyMap.Close):
+				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
+			}
+		case stateError:
+			switch {
+			case key.Matches(msg, c.keyMap.Select):
+				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
+			case key.Matches(msg, c.keyMap.Close):
+				return c, util.CmdHandler(dialogs.CloseDialogMsg{})
+			}
+		}
+
+	case agent.AgentEvent:
+		if msg.Type == agent.AgentEventTypeSummarize {
+			if msg.Error != nil {
+				c.state = stateError
+				c.progress = "Error: " + msg.Error.Error()
+			} else if msg.Done {
+				return c, util.CmdHandler(
+					dialogs.CloseDialogMsg{},
+				)
+			} else {
+				c.progress = msg.Progress
+			}
+		}
+		return c, nil
+	}
+
+	return c, nil
+}
+
+func (c *compactDialogCmp) startCompaction() tea.Cmd {
+	c.state = stateCompacting
+	c.progress = "Starting summarization..."
+	return func() tea.Msg {
+		err := c.agent.Summarize(context.Background(), c.sessionID)
+		if err != nil {
+			c.state = stateError
+			c.progress = "Error: " + err.Error()
+		}
+		return nil
+	}
+}
+
+func (c *compactDialogCmp) renderButtons() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	buttons := []core.ButtonOpts{
+		{
+			Text:           "Yes",
+			UnderlineIndex: 0, // "Y"
+			Selected:       c.selected == 0,
+		},
+		{
+			Text:           "No",
+			UnderlineIndex: 0, // "N"
+			Selected:       c.selected == 1,
+		},
+	}
+
+	content := core.SelectableButtons(buttons, "  ")
+
+	return baseStyle.AlignHorizontal(lipgloss.Right).Width(c.width - 4).Render(content)
+}
+
+func (c *compactDialogCmp) renderContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	switch c.state {
+	case stateConfirm:
+		explanation := t.S().Text.
+			Width(c.width - 4).
+			Render("This will summarize the current session and reset the context. The conversation history will be condensed into a summary to free up context space while preserving important information.")
+
+		question := t.S().Text.
+			Width(c.width - 4).
+			Render("Do you want to continue?")
+
+		return baseStyle.Render(lipgloss.JoinVertical(
+			lipgloss.Left,
+			explanation,
+			"",
+			question,
+		))
+	case stateCompacting:
+		return baseStyle.Render(lipgloss.JoinVertical(
+			lipgloss.Left,
+			c.progress,
+			"",
+			"Please wait...",
+		))
+	case stateError:
+		return baseStyle.Render(lipgloss.JoinVertical(
+			lipgloss.Left,
+			c.progress,
+			"",
+			"Press Enter to close",
+		))
+	}
+	return ""
+}
+
+func (c *compactDialogCmp) render() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	var title string
+	switch c.state {
+	case stateConfirm:
+		title = "Compact Session"
+	case stateCompacting:
+		title = "Compacting Session"
+	case stateError:
+		title = "Compact Failed"
+	}
+
+	titleView := core.Title(title, c.width-4)
+	content := c.renderContent()
+
+	var dialogContent string
+	if c.state == stateConfirm {
+		buttons := c.renderButtons()
+		dialogContent = lipgloss.JoinVertical(
+			lipgloss.Top,
+			titleView,
+			"",
+			content,
+			"",
+			buttons,
+			"",
+		)
+	} else {
+		dialogContent = lipgloss.JoinVertical(
+			lipgloss.Top,
+			titleView,
+			"",
+			content,
+			"",
+		)
+	}
+
+	return baseStyle.
+		Padding(0, 1).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus).
+		Width(c.width).
+		Render(dialogContent)
+}
+
+func (c *compactDialogCmp) View() tea.View {
+	return tea.NewView(c.render())
+}
+
+// SetSize sets the size of the component.
+func (c *compactDialogCmp) SetSize() tea.Cmd {
+	c.width = min(90, c.wWidth)
+	c.height = min(15, c.wHeight)
+	return nil
+}
+
+func (c *compactDialogCmp) Position() (int, int) {
+	row := (c.wHeight / 2) - (c.height / 2)
+	col := (c.wWidth / 2) - (c.width / 2)
+	return row, col
+}
+
+// ID implements CompactDialog.
+func (c *compactDialogCmp) ID() dialogs.DialogID {
+	return CompactDialogID
+}

internal/tui/components/dialogs/compact/keys.go 🔗

@@ -0,0 +1,61 @@
+package compact
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+// KeyMap defines the key bindings for the compact dialog.
+type KeyMap struct {
+	ChangeSelection key.Binding
+	Select          key.Binding
+	Y               key.Binding
+	N               key.Binding
+	Close           key.Binding
+}
+
+// DefaultKeyMap returns the default key bindings for the compact dialog.
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		ChangeSelection: key.NewBinding(
+			key.WithKeys("tab", "left", "right", "h", "l"),
+			key.WithHelp("tab/←/→", "toggle selection"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "confirm"),
+		),
+		Y: key.NewBinding(
+			key.WithKeys("y"),
+			key.WithHelp("y", "yes"),
+		),
+		N: key.NewBinding(
+			key.WithKeys("n"),
+			key.WithHelp("n", "no"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.ChangeSelection,
+		k.Select,
+		k.Close,
+	}
+}

internal/tui/components/dialogs/dialogs.go 🔗

@@ -0,0 +1,162 @@
+package dialogs
+
+import (
+	"slices"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+type DialogID string
+
+// DialogModel represents a dialog component that can be displayed.
+type DialogModel interface {
+	util.Model
+	Position() (int, int)
+	ID() DialogID
+}
+
+// CloseCallback allows dialogs to perform cleanup when closed.
+type CloseCallback interface {
+	Close() tea.Cmd
+}
+
+// OpenDialogMsg is sent to open a new dialog with specified dimensions.
+type OpenDialogMsg struct {
+	Model DialogModel
+}
+
+// CloseDialogMsg is sent to close the topmost dialog.
+type CloseDialogMsg struct{}
+
+// DialogCmp manages a stack of dialogs with keyboard navigation.
+type DialogCmp interface {
+	tea.Model
+
+	Dialogs() []DialogModel
+	HasDialogs() bool
+	GetLayers() []*lipgloss.Layer
+	ActiveView() *tea.View
+	ActiveDialogId() DialogID
+}
+
+type dialogCmp struct {
+	width, height int
+	dialogs       []DialogModel
+	idMap         map[DialogID]int
+	keyMap        KeyMap
+}
+
+// NewDialogCmp creates a new dialog manager.
+func NewDialogCmp() DialogCmp {
+	return dialogCmp{
+		dialogs: []DialogModel{},
+		keyMap:  DefaultKeyMap(),
+		idMap:   make(map[DialogID]int),
+	}
+}
+
+func (d dialogCmp) Init() tea.Cmd {
+	return nil
+}
+
+// Update handles dialog lifecycle and forwards messages to the active dialog.
+func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		var cmds []tea.Cmd
+		d.width = msg.Width
+		d.height = msg.Height
+		for i := range d.dialogs {
+			u, cmd := d.dialogs[i].Update(msg)
+			d.dialogs[i] = u.(DialogModel)
+			cmds = append(cmds, cmd)
+		}
+		return d, tea.Batch(cmds...)
+	case OpenDialogMsg:
+		return d.handleOpen(msg)
+	case CloseDialogMsg:
+		if len(d.dialogs) == 0 {
+			return d, nil
+		}
+		inx := len(d.dialogs) - 1
+		dialog := d.dialogs[inx]
+		delete(d.idMap, dialog.ID())
+		d.dialogs = d.dialogs[:len(d.dialogs)-1]
+		if closeable, ok := dialog.(CloseCallback); ok {
+			return d, closeable.Close()
+		}
+		return d, nil
+	}
+	if d.HasDialogs() {
+		lastIndex := len(d.dialogs) - 1
+		u, cmd := d.dialogs[lastIndex].Update(msg)
+		d.dialogs[lastIndex] = u.(DialogModel)
+		return d, cmd
+	}
+	return d, nil
+}
+
+func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
+	if d.HasDialogs() {
+		dialog := d.dialogs[len(d.dialogs)-1]
+		if dialog.ID() == msg.Model.ID() {
+			return d, nil // Do not open a dialog if it's already the topmost one
+		}
+		if dialog.ID() == "quit" {
+			return d, nil // Do not open dialogs on top of quit
+		}
+	}
+	// if the dialog is already in the stack make it the last item
+	if _, ok := d.idMap[msg.Model.ID()]; ok {
+		existing := d.dialogs[d.idMap[msg.Model.ID()]]
+		// Reuse the model so we keep the state
+		msg.Model = existing
+		d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
+	}
+	d.idMap[msg.Model.ID()] = len(d.dialogs)
+	d.dialogs = append(d.dialogs, msg.Model)
+	var cmds []tea.Cmd
+	cmd := msg.Model.Init()
+	cmds = append(cmds, cmd)
+	_, cmd = msg.Model.Update(tea.WindowSizeMsg{
+		Width:  d.width,
+		Height: d.height,
+	})
+	cmds = append(cmds, cmd)
+	return d, tea.Batch(cmds...)
+}
+
+func (d dialogCmp) Dialogs() []DialogModel {
+	return d.dialogs
+}
+
+func (d dialogCmp) ActiveView() *tea.View {
+	if len(d.dialogs) == 0 {
+		return nil
+	}
+	view := d.dialogs[len(d.dialogs)-1].View()
+	return &view
+}
+
+func (d dialogCmp) ActiveDialogId() DialogID {
+	if len(d.dialogs) == 0 {
+		return ""
+	}
+	return d.dialogs[len(d.dialogs)-1].ID()
+}
+
+func (d dialogCmp) GetLayers() []*lipgloss.Layer {
+	layers := []*lipgloss.Layer{}
+	for _, dialog := range d.Dialogs() {
+		dialogView := dialog.View().String()
+		row, col := dialog.Position()
+		layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
+	}
+	return layers
+}
+
+func (d dialogCmp) HasDialogs() bool {
+	return len(d.dialogs) > 0
+}

internal/tui/components/dialogs/filepicker/filepicker.go 🔗

@@ -0,0 +1,229 @@
+package filepicker
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/filepicker"
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/components/image"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+const (
+	maxAttachmentSize  = int64(5 * 1024 * 1024) // 5MB
+	FilePickerID       = "filepicker"
+	fileSelectionHight = 10
+)
+
+type FilePickedMsg struct {
+	Attachment message.Attachment
+}
+
+type FilePicker interface {
+	dialogs.DialogModel
+}
+
+type model struct {
+	wWidth          int
+	wHeight         int
+	width           int
+	filePicker      filepicker.Model
+	highlightedFile string
+	image           image.Model
+	keyMap          KeyMap
+	help            help.Model
+}
+
+func NewFilePickerCmp() FilePicker {
+	t := styles.CurrentTheme()
+	fp := filepicker.New()
+	fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
+	fp.CurrentDirectory, _ = os.UserHomeDir()
+	fp.ShowPermissions = false
+	fp.ShowSize = false
+	fp.AutoHeight = false
+	fp.Styles = t.S().FilePicker
+	fp.Cursor = ""
+	fp.SetHeight(fileSelectionHight)
+
+	image := image.New(1, 1, "")
+
+	help := help.New()
+	help.Styles = t.S().Help
+	return &model{
+		filePicker: fp,
+		image:      image,
+		keyMap:     DefaultKeyMap(),
+		help:       help,
+	}
+}
+
+func (m *model) Init() tea.Cmd {
+	return m.filePicker.Init()
+}
+
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.wWidth = msg.Width
+		m.wHeight = msg.Height
+		m.width = min(70, m.wWidth)
+		styles := m.filePicker.Styles
+		styles.Directory = styles.Directory.Width(m.width - 4)
+		styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
+		styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
+		styles.File = styles.File.Width(m.width)
+		m.filePicker.Styles = styles
+		return m, nil
+	case tea.KeyPressMsg:
+		if key.Matches(msg, m.keyMap.Close) {
+			return m, util.CmdHandler(dialogs.CloseDialogMsg{})
+		}
+		if key.Matches(msg, m.filePicker.KeyMap.Back) {
+			// make sure we don't go back if we are at the home directory
+			homeDir, _ := os.UserHomeDir()
+			if m.filePicker.CurrentDirectory == homeDir {
+				return m, nil
+			}
+		}
+	}
+
+	var cmd tea.Cmd
+	var cmds []tea.Cmd
+	m.filePicker, cmd = m.filePicker.Update(msg)
+	cmds = append(cmds, cmd)
+	if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
+		w, h := m.imagePreviewSize()
+		cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
+		cmds = append(cmds, cmd)
+	}
+	m.highlightedFile = m.currentImage()
+
+	// Did the user select a file?
+	if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
+		// Get the path of the selected file.
+		return m, tea.Sequence(
+			util.CmdHandler(dialogs.CloseDialogMsg{}),
+			func() tea.Msg {
+				isFileLarge, err := ValidateFileSize(path, maxAttachmentSize)
+				if err != nil {
+					logging.ErrorPersist("unable to read the image")
+					return nil
+				}
+				if isFileLarge {
+					logging.ErrorPersist("file too large, max 5MB")
+					return nil
+				}
+
+				content, err := os.ReadFile(path)
+				if err != nil {
+					logging.ErrorPersist("Unable read selected file")
+					return nil
+				}
+
+				mimeBufferSize := min(512, len(content))
+				mimeType := http.DetectContentType(content[:mimeBufferSize])
+				fileName := filepath.Base(path)
+				attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
+				return FilePickedMsg{
+					Attachment: attachment,
+				}
+			},
+		)
+	}
+	m.image, cmd = m.image.Update(msg)
+	cmds = append(cmds, cmd)
+	return m, tea.Batch(cmds...)
+}
+
+func (m *model) View() tea.View {
+	t := styles.CurrentTheme()
+
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
+		m.imagePreview(),
+		m.filePicker.View(),
+		t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
+	)
+	return tea.NewView(m.style().Render(content))
+}
+
+func (m *model) currentImage() string {
+	for _, ext := range m.filePicker.AllowedTypes {
+		if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) {
+			return m.filePicker.HighlightedPath()
+		}
+	}
+	return ""
+}
+
+func (m *model) imagePreview() string {
+	t := styles.CurrentTheme()
+	w, h := m.imagePreviewSize()
+	if m.currentImage() == "" {
+		imgPreview := t.S().Base.
+			Width(w).
+			Height(h).
+			Background(t.BgOverlay)
+
+		return m.imagePreviewStyle().Render(imgPreview.Render())
+	}
+
+	return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
+}
+
+func (m *model) imagePreviewStyle() lipgloss.Style {
+	t := styles.CurrentTheme()
+	return t.S().Base.Padding(1, 1, 1, 1)
+}
+
+func (m *model) imagePreviewSize() (int, int) {
+	return m.width - 4, min(20, m.wHeight/2)
+}
+
+func (m *model) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+	return t.S().Base.
+		Width(m.width).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+}
+
+// ID implements FilePicker.
+func (m *model) ID() dialogs.DialogID {
+	return FilePickerID
+}
+
+// Position implements FilePicker.
+func (m *model) Position() (int, int) {
+	row := m.wHeight/4 - 2 // just a bit above the center
+	col := m.wWidth / 2
+	col -= m.width / 2
+	return row, col
+}
+
+func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		return false, fmt.Errorf("error getting file info: %w", err)
+	}
+
+	if fileInfo.Size() > sizeLimit {
+		return true, nil
+	}
+
+	return false, nil
+}

internal/tui/components/dialogs/filepicker/keys.go 🔗

@@ -0,0 +1,69 @@
+package filepicker
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+// KeyMap defines keyboard bindings for dialog management.
+type KeyMap struct {
+	Select,
+	Down,
+	Up,
+	Forward,
+	Backward,
+	Close key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Select: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "accept"),
+		),
+		Down: key.NewBinding(
+			key.WithKeys("down", "j"),
+			key.WithHelp("down/j", "move down"),
+		),
+		Up: key.NewBinding(
+			key.WithKeys("up", "k"),
+			key.WithHelp("up/k", "move up"),
+		),
+		Forward: key.NewBinding(
+			key.WithKeys("right", "l"),
+			key.WithHelp("right/l", "move forward"),
+		),
+		Backward: key.NewBinding(
+			key.WithKeys("left", "h"),
+			key.WithHelp("left/h", "move backward"),
+		),
+
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "close/exit"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		key.NewBinding(
+			key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
+			key.WithHelp("↑↓←→", "navigate"),
+		),
+		k.Select,
+		k.Close,
+	}
+}

internal/tui/components/dialogs/init/init.go 🔗

@@ -0,0 +1,213 @@
+package init
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/config"
+	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+const InitDialogID dialogs.DialogID = "init"
+
+// InitDialogCmp is a component that asks the user if they want to initialize the project.
+type InitDialogCmp interface {
+	dialogs.DialogModel
+}
+
+type initDialogCmp struct {
+	wWidth, wHeight int
+	width, height   int
+	selected        int
+	keyMap          KeyMap
+}
+
+// NewInitDialogCmp creates a new InitDialogCmp.
+func NewInitDialogCmp() InitDialogCmp {
+	return &initDialogCmp{
+		selected: 0,
+		keyMap:   DefaultKeyMap(),
+	}
+}
+
+// Init implements tea.Model.
+func (m *initDialogCmp) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements tea.Model.
+func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.wWidth = msg.Width
+		m.wHeight = msg.Height
+		cmd := m.SetSize()
+		return m, cmd
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.Close):
+			return m, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				m.handleInitialization(false),
+			)
+		case key.Matches(msg, m.keyMap.ChangeSelection):
+			m.selected = (m.selected + 1) % 2
+			return m, nil
+		case key.Matches(msg, m.keyMap.Select):
+			return m, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				m.handleInitialization(m.selected == 0),
+			)
+		case key.Matches(msg, m.keyMap.Y):
+			return m, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				m.handleInitialization(true),
+			)
+		case key.Matches(msg, m.keyMap.N):
+			return m, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				m.handleInitialization(false),
+			)
+		}
+	}
+	return m, nil
+}
+
+func (m *initDialogCmp) renderButtons() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	buttons := []core.ButtonOpts{
+		{
+			Text:           "Yes",
+			UnderlineIndex: 0, // "Y"
+			Selected:       m.selected == 0,
+		},
+		{
+			Text:           "No",
+			UnderlineIndex: 0, // "N"
+			Selected:       m.selected == 1,
+		},
+	}
+
+	content := core.SelectableButtons(buttons, "  ")
+
+	return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content)
+}
+
+func (m *initDialogCmp) renderContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	explanation := t.S().Text.
+		Width(m.width - 4).
+		Render("Initialization generates a new Crush.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
+
+	question := t.S().Text.
+		Width(m.width - 4).
+		Render("Would you like to initialize this project?")
+
+	return baseStyle.Render(lipgloss.JoinVertical(
+		lipgloss.Left,
+		explanation,
+		"",
+		question,
+	))
+}
+
+func (m *initDialogCmp) render() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+	title := core.Title("Initialize Project", m.width-4)
+
+	content := m.renderContent()
+	buttons := m.renderButtons()
+
+	dialogContent := lipgloss.JoinVertical(
+		lipgloss.Top,
+		title,
+		"",
+		content,
+		"",
+		buttons,
+		"",
+	)
+
+	return baseStyle.
+		Padding(0, 1).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus).
+		Width(m.width).
+		Render(dialogContent)
+}
+
+// View implements tea.Model.
+func (m *initDialogCmp) View() tea.View {
+	return tea.NewView(m.render())
+}
+
+// SetSize sets the size of the component.
+func (m *initDialogCmp) SetSize() tea.Cmd {
+	m.width = min(90, m.wWidth)
+	m.height = min(15, m.wHeight)
+	return nil
+}
+
+// ID implements DialogModel.
+func (m *initDialogCmp) ID() dialogs.DialogID {
+	return InitDialogID
+}
+
+// Position implements DialogModel.
+func (m *initDialogCmp) Position() (int, int) {
+	row := (m.wHeight / 2) - (m.height / 2)
+	col := (m.wWidth / 2) - (m.width / 2)
+	return row, col
+}
+
+// handleInitialization handles the initialization logic when the dialog is closed.
+func (m *initDialogCmp) handleInitialization(initialize bool) tea.Cmd {
+	if initialize {
+		// Run the initialization command
+		prompt := `Please analyze this codebase and create a Crush.md file containing:
+1. Build/lint/test commands - especially for running a single test
+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
+
+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
+If there's already a crush.md, improve it.
+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
+
+		// Mark the project as initialized
+		if err := config.MarkProjectInitialized(); err != nil {
+			return util.ReportError(err)
+		}
+
+		return tea.Sequence(
+			util.CmdHandler(cmpChat.SessionClearedMsg{}),
+			util.CmdHandler(cmpChat.SendMsg{
+				Text: prompt,
+			}),
+		)
+	} else {
+		// Mark the project as initialized without running the command
+		if err := config.MarkProjectInitialized(); err != nil {
+			return util.ReportError(err)
+		}
+	}
+	return nil
+}
+
+// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
+type CloseInitDialogMsg struct {
+	Initialize bool
+}
+
+// ShowInitDialogMsg is a message that is sent to show the init dialog.
+type ShowInitDialogMsg struct {
+	Show bool
+}

internal/tui/components/dialogs/init/keys.go 🔗

@@ -0,0 +1,59 @@
+package init
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	ChangeSelection,
+	Select,
+	Y,
+	N,
+	Close key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		ChangeSelection: key.NewBinding(
+			key.WithKeys("tab", "left", "right", "h", "l"),
+			key.WithHelp("tab/←/→", "toggle selection"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "confirm"),
+		),
+		Y: key.NewBinding(
+			key.WithKeys("y"),
+			key.WithHelp("y", "yes"),
+		),
+		N: key.NewBinding(
+			key.WithKeys("n"),
+			key.WithHelp("n", "no"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.ChangeSelection,
+		k.Select,
+		k.Close,
+	}
+}

internal/tui/components/dialogs/keys.go 🔗

@@ -0,0 +1,37 @@
+package dialogs
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+// KeyMap defines keyboard bindings for dialog management.
+type KeyMap struct {
+	Close key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Close,
+	}
+}

internal/tui/components/dialogs/models/keys.go 🔗

@@ -0,0 +1,58 @@
+package models
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Select,
+	Next,
+	Previous,
+	Close key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Select: key.NewBinding(
+			key.WithKeys("enter", "tab", "ctrl+y"),
+			key.WithHelp("enter", "confirm"),
+		),
+		Next: key.NewBinding(
+			key.WithKeys("down", "ctrl+n"),
+			key.WithHelp("↓", "next item"),
+		),
+		Previous: key.NewBinding(
+			key.WithKeys("up", "ctrl+p"),
+			key.WithHelp("↑", "previous item"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		key.NewBinding(
+
+			key.WithKeys("down", "up"),
+			key.WithHelp("↑↓", "choose"),
+		),
+		k.Select,
+		k.Close,
+	}
+}

internal/tui/components/dialogs/models/models.go 🔗

@@ -0,0 +1,263 @@
+package models
+
+import (
+	"slices"
+
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/tui/components/completions"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+const (
+	ModelsDialogID dialogs.DialogID = "models"
+
+	defaultWidth = 60
+)
+
+// ModelSelectedMsg is sent when a model is selected
+type ModelSelectedMsg struct {
+	Model models.Model
+}
+
+// CloseModelDialogMsg is sent when a model is selected
+type CloseModelDialogMsg struct{}
+
+// ModelDialog interface for the model selection dialog
+type ModelDialog interface {
+	dialogs.DialogModel
+}
+
+type modelDialogCmp struct {
+	width   int
+	wWidth  int // Width of the terminal window
+	wHeight int // Height of the terminal window
+
+	modelList list.ListModel
+	keyMap    KeyMap
+	help      help.Model
+}
+
+func NewModelDialogCmp() ModelDialog {
+	listKeyMap := list.DefaultKeyMap()
+	keyMap := DefaultKeyMap()
+
+	listKeyMap.Down.SetEnabled(false)
+	listKeyMap.Up.SetEnabled(false)
+	listKeyMap.NDown.SetEnabled(false)
+	listKeyMap.NUp.SetEnabled(false)
+	listKeyMap.HalfPageDown.SetEnabled(false)
+	listKeyMap.HalfPageUp.SetEnabled(false)
+	listKeyMap.Home.SetEnabled(false)
+	listKeyMap.End.SetEnabled(false)
+
+	listKeyMap.DownOneItem = keyMap.Next
+	listKeyMap.UpOneItem = keyMap.Previous
+
+	t := styles.CurrentTheme()
+	inputStyle := t.S().Base.Padding(0, 1, 0, 1)
+	modelList := list.New(
+		list.WithFilterable(true),
+		list.WithKeyMap(listKeyMap),
+		list.WithInputStyle(inputStyle),
+		list.WithWrapNavigation(true),
+	)
+	help := help.New()
+	help.Styles = t.S().Help
+
+	return &modelDialogCmp{
+		modelList: modelList,
+		width:     defaultWidth,
+		keyMap:    DefaultKeyMap(),
+		help:      help,
+	}
+}
+
+var ProviderPopularity = map[models.ModelProvider]int{
+	models.ProviderAnthropic:  1,
+	models.ProviderOpenAI:     2,
+	models.ProviderGemini:     3,
+	models.ProviderGROQ:       4,
+	models.ProviderOpenRouter: 5,
+	models.ProviderBedrock:    6,
+	models.ProviderAzure:      7,
+	models.ProviderVertexAI:   8,
+	models.ProviderXAI:        9,
+}
+
+var ProviderName = map[models.ModelProvider]string{
+	models.ProviderAnthropic:  "Anthropic",
+	models.ProviderOpenAI:     "OpenAI",
+	models.ProviderGemini:     "Gemini",
+	models.ProviderGROQ:       "Groq",
+	models.ProviderOpenRouter: "OpenRouter",
+	models.ProviderBedrock:    "AWS Bedrock",
+	models.ProviderAzure:      "Azure",
+	models.ProviderVertexAI:   "VertexAI",
+	models.ProviderXAI:        "xAI",
+}
+
+func (m *modelDialogCmp) Init() tea.Cmd {
+	cfg := config.Get()
+	enabledProviders := getEnabledProviders(cfg)
+
+	modelItems := []util.Model{}
+	for _, provider := range enabledProviders {
+		name, ok := ProviderName[provider]
+		if !ok {
+			name = string(provider) // Fallback to provider ID if name is not defined
+		}
+		modelItems = append(modelItems, commands.NewItemSection(name))
+		for _, model := range getModelsForProvider(provider) {
+			modelItems = append(modelItems, completions.NewCompletionItem(model.Name, model))
+		}
+	}
+	m.modelList.SetItems(modelItems)
+	return m.modelList.Init()
+}
+
+func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.wWidth = msg.Width
+		m.wHeight = msg.Height
+		return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.Select):
+			selectedItemInx := m.modelList.SelectedIndex()
+			if selectedItemInx == list.NoSelection {
+				return m, nil // No item selected, do nothing
+			}
+			items := m.modelList.Items()
+			selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(models.Model)
+
+			return m, tea.Sequence(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(ModelSelectedMsg{Model: selectedItem}),
+			)
+		case key.Matches(msg, m.keyMap.Close):
+			return m, util.CmdHandler(dialogs.CloseDialogMsg{})
+		default:
+			u, cmd := m.modelList.Update(msg)
+			m.modelList = u.(list.ListModel)
+			return m, cmd
+		}
+	}
+	return m, nil
+}
+
+func (m *modelDialogCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	listView := m.modelList.View()
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-4)),
+		listView.String(),
+		"",
+		t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
+	)
+	v := tea.NewView(m.style().Render(content))
+	if listView.Cursor() != nil {
+		c := m.moveCursor(listView.Cursor())
+		v.SetCursor(c)
+	}
+	return v
+}
+
+func (m *modelDialogCmp) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+	return t.S().Base.
+		Width(m.width).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+}
+
+func (m *modelDialogCmp) listWidth() int {
+	return defaultWidth - 2 // 4 for padding
+}
+
+func (m *modelDialogCmp) listHeight() int {
+	listHeigh := len(m.modelList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
+	return min(listHeigh, m.wHeight/2)
+}
+
+func GetSelectedModel(cfg *config.Config) models.Model {
+	agentCfg := cfg.Agents[config.AgentCoder]
+	selectedModelId := agentCfg.Model
+	return models.SupportedModels[selectedModelId]
+}
+
+func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
+	var providers []models.ModelProvider
+	for providerId, provider := range cfg.Providers {
+		if !provider.Disabled {
+			providers = append(providers, providerId)
+		}
+	}
+
+	// Sort by provider popularity
+	slices.SortFunc(providers, func(a, b models.ModelProvider) int {
+		rA := ProviderPopularity[a]
+		rB := ProviderPopularity[b]
+
+		// models not included in popularity ranking default to last
+		if rA == 0 {
+			rA = 999
+		}
+		if rB == 0 {
+			rB = 999
+		}
+		return rA - rB
+	})
+	return providers
+}
+
+func getModelsForProvider(provider models.ModelProvider) []models.Model {
+	var providerModels []models.Model
+	for _, model := range models.SupportedModels {
+		if model.Provider == provider {
+			providerModels = append(providerModels, model)
+		}
+	}
+
+	// reverse alphabetical order (if llm naming was consistent latest would appear first)
+	slices.SortFunc(providerModels, func(a, b models.Model) int {
+		if a.Name > b.Name {
+			return -1
+		} else if a.Name < b.Name {
+			return 1
+		}
+		return 0
+	})
+
+	return providerModels
+}
+
+func (m *modelDialogCmp) Position() (int, int) {
+	row := m.wHeight/4 - 2 // just a bit above the center
+	col := m.wWidth / 2
+	col -= m.width / 2
+	return row, col
+}
+
+func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
+	row, col := m.Position()
+	offset := row + 3 // Border + title
+	cursor.Y += offset
+	cursor.X = cursor.X + col + 2
+	return cursor
+}
+
+func (m *modelDialogCmp) ID() dialogs.DialogID {
+	return ModelsDialogID
+}

internal/tui/components/dialogs/permissions/keys.go 🔗

@@ -0,0 +1,70 @@
+package permissions
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Left,
+	Right,
+	Tab,
+	Select,
+	Allow,
+	AllowSession,
+	Deny key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Left: key.NewBinding(
+			key.WithKeys("left", "h"),
+			key.WithHelp("←", "previous"),
+		),
+		Right: key.NewBinding(
+			key.WithKeys("right", "l"),
+			key.WithHelp("→", "next"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "switch"),
+		),
+		Allow: key.NewBinding(
+			key.WithKeys("a", "ctrl+a"),
+			key.WithHelp("a", "allow"),
+		),
+		AllowSession: key.NewBinding(
+			key.WithKeys("s", "ctrl+s"),
+			key.WithHelp("s", "allow session"),
+		),
+		Deny: key.NewBinding(
+			key.WithKeys("d", "ctrl+d"),
+			key.WithHelp("d", "deny"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter", "tab", "ctrl+y"),
+			key.WithHelp("enter", "confirm"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Allow,
+		k.AllowSession,
+		k.Deny,
+		k.Select,
+	}
+}

internal/tui/components/dialogs/permissions/permissions.go 🔗

@@ -0,0 +1,473 @@
+package permissions
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/viewport"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fileutil"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+)
+
+type PermissionAction string
+
+// Permission responses
+const (
+	PermissionAllow           PermissionAction = "allow"
+	PermissionAllowForSession PermissionAction = "allow_session"
+	PermissionDeny            PermissionAction = "deny"
+
+	PermissionsDialogID dialogs.DialogID = "permissions"
+)
+
+// PermissionResponseMsg represents the user's response to a permission request
+type PermissionResponseMsg struct {
+	Permission permission.PermissionRequest
+	Action     PermissionAction
+}
+
+// PermissionDialogCmp interface for permission dialog component
+type PermissionDialogCmp interface {
+	dialogs.DialogModel
+}
+
+// permissionDialogCmp is the implementation of PermissionDialog
+type permissionDialogCmp struct {
+	wWidth          int
+	wHeight         int
+	width           int
+	height          int
+	permission      permission.PermissionRequest
+	contentViewPort viewport.Model
+	selectedOption  int // 0: Allow, 1: Allow for session, 2: Deny
+
+	keyMap KeyMap
+}
+
+func NewPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialogCmp {
+	// Create viewport for content
+	contentViewport := viewport.New()
+	return &permissionDialogCmp{
+		contentViewPort: contentViewport,
+		selectedOption:  0, // Default to "Allow"
+		permission:      permission,
+		keyMap:          DefaultKeyMap(),
+	}
+}
+
+func (p *permissionDialogCmp) Init() tea.Cmd {
+	return p.contentViewPort.Init()
+}
+
+func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		p.wWidth = msg.Width
+		p.wHeight = msg.Height
+		cmd := p.SetSize()
+		cmds = append(cmds, cmd)
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
+			p.selectedOption = (p.selectedOption + 1) % 3
+			return p, nil
+		case key.Matches(msg, p.keyMap.Left):
+			p.selectedOption = (p.selectedOption + 2) % 3
+		case key.Matches(msg, p.keyMap.Select):
+			return p, p.selectCurrentOption()
+		case key.Matches(msg, p.keyMap.Allow):
+			return p, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
+			)
+		case key.Matches(msg, p.keyMap.AllowSession):
+			return p, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
+			)
+		case key.Matches(msg, p.keyMap.Deny):
+			return p, tea.Batch(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
+			)
+		default:
+			// Pass other keys to viewport
+			viewPort, cmd := p.contentViewPort.Update(msg)
+			p.contentViewPort = viewPort
+			cmds = append(cmds, cmd)
+		}
+	}
+
+	return p, tea.Batch(cmds...)
+}
+
+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
+	var action PermissionAction
+
+	switch p.selectedOption {
+	case 0:
+		action = PermissionAllow
+	case 1:
+		action = PermissionAllowForSession
+	case 2:
+		action = PermissionDeny
+	}
+
+	return tea.Batch(
+		util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
+		util.CmdHandler(dialogs.CloseDialogMsg{}),
+	)
+}
+
+func (p *permissionDialogCmp) renderButtons() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	buttons := []core.ButtonOpts{
+		{
+			Text:           "Allow",
+			UnderlineIndex: 0, // "A"
+			Selected:       p.selectedOption == 0,
+		},
+		{
+			Text:           "Allow for Session",
+			UnderlineIndex: 10, // "S" in "Session"
+			Selected:       p.selectedOption == 1,
+		},
+		{
+			Text:           "Deny",
+			UnderlineIndex: 0, // "D"
+			Selected:       p.selectedOption == 2,
+		},
+	}
+
+	content := core.SelectableButtons(buttons, "  ")
+
+	return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
+}
+
+func (p *permissionDialogCmp) renderHeader() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+
+	toolKey := t.S().Muted.Render("Tool")
+	toolValue := t.S().Text.
+		Width(p.width - lipgloss.Width(toolKey)).
+		Render(fmt.Sprintf(" %s", p.permission.ToolName))
+
+	pathKey := t.S().Muted.Render("Path")
+	pathValue := t.S().Text.
+		Width(p.width - lipgloss.Width(pathKey)).
+		Render(fmt.Sprintf(" %s", fileutil.PrettyPath(p.permission.Path)))
+
+	headerParts := []string{
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			toolKey,
+			toolValue,
+		),
+		baseStyle.Render(strings.Repeat(" ", p.width)),
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			pathKey,
+			pathValue,
+		),
+		baseStyle.Render(strings.Repeat(" ", p.width)),
+	}
+
+	// Add tool-specific header information
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
+	case tools.EditToolName:
+		params := p.permission.Params.(tools.EditPermissionsParams)
+		fileKey := t.S().Muted.Render("File")
+		filePath := t.S().Text.
+			Width(p.width - lipgloss.Width(fileKey)).
+			Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath)))
+		headerParts = append(headerParts,
+			lipgloss.JoinHorizontal(
+				lipgloss.Left,
+				fileKey,
+				filePath,
+			),
+			baseStyle.Render(strings.Repeat(" ", p.width)),
+		)
+
+	case tools.WriteToolName:
+		params := p.permission.Params.(tools.WritePermissionsParams)
+		fileKey := t.S().Muted.Render("File")
+		filePath := t.S().Text.
+			Width(p.width - lipgloss.Width(fileKey)).
+			Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath)))
+		headerParts = append(headerParts,
+			lipgloss.JoinHorizontal(
+				lipgloss.Left,
+				fileKey,
+				filePath,
+			),
+			baseStyle.Render(strings.Repeat(" ", p.width)),
+		)
+	case tools.FetchToolName:
+		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
+	}
+
+	return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
+}
+
+func (p *permissionDialogCmp) renderBashContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base.Background(t.BgSubtle)
+	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
+		content := pr.Command
+		t := styles.CurrentTheme()
+		content = strings.TrimSpace(content)
+		content = "\n" + content + "\n"
+		lines := strings.Split(content, "\n")
+
+		width := p.width - 4
+		var out []string
+		for _, ln := range lines {
+			ln = " " + ln // left padding
+			if len(ln) > width {
+				ln = ansi.Truncate(ln, width, "…")
+			}
+			out = append(out, t.S().Muted.
+				Width(width).
+				Foreground(t.FgBase).
+				Background(t.BgSubtle).
+				Render(ln))
+		}
+
+		// Use the cache for markdown rendering
+		renderedContent := strings.Join(out, "\n")
+		finalContent := baseStyle.
+			Width(p.contentViewPort.Width()).
+			Render(renderedContent)
+
+		contentHeight := min(p.height-9, lipgloss.Height(finalContent))
+		p.contentViewPort.SetHeight(contentHeight)
+		p.contentViewPort.SetContent(finalContent)
+		return p.styleViewport()
+	}
+	return ""
+}
+
+func (p *permissionDialogCmp) renderEditContent() string {
+	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
+		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
+		})
+
+		contentHeight := min(p.height-9, lipgloss.Height(diff))
+		p.contentViewPort.SetHeight(contentHeight)
+		p.contentViewPort.SetContent(diff)
+		return p.styleViewport()
+	}
+	return ""
+}
+
+func (p *permissionDialogCmp) renderPatchContent() string {
+	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
+		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
+		})
+
+		contentHeight := min(p.height-9, lipgloss.Height(diff))
+		p.contentViewPort.SetHeight(contentHeight)
+		p.contentViewPort.SetContent(diff)
+		return p.styleViewport()
+	}
+	return ""
+}
+
+func (p *permissionDialogCmp) renderWriteContent() string {
+	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
+		// Use the cache for diff rendering
+		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
+		})
+
+		contentHeight := min(p.height-9, lipgloss.Height(diff))
+		p.contentViewPort.SetHeight(contentHeight)
+		p.contentViewPort.SetContent(diff)
+		return p.styleViewport()
+	}
+	return ""
+}
+
+func (p *permissionDialogCmp) renderFetchContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base.Background(t.BgSubtle)
+	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
+		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
+
+		// Use the cache for markdown rendering
+		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+			r := styles.GetMarkdownRenderer(p.width - 4)
+			s, err := r.Render(content)
+			return s, err
+		})
+
+		finalContent := baseStyle.
+			Width(p.contentViewPort.Width()).
+			Render(renderedContent)
+
+		contentHeight := min(p.height-9, lipgloss.Height(finalContent))
+		p.contentViewPort.SetHeight(contentHeight)
+		p.contentViewPort.SetContent(finalContent)
+		return p.styleViewport()
+	}
+	return ""
+}
+
+func (p *permissionDialogCmp) renderDefaultContent() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base.Background(t.BgSubtle)
+
+	content := p.permission.Description
+
+	// Use the cache for markdown rendering
+	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+		r := styles.GetMarkdownRenderer(p.width - 4)
+		s, err := r.Render(content)
+		return s, err
+	})
+
+	finalContent := baseStyle.
+		Width(p.contentViewPort.Width()).
+		Render(renderedContent)
+	p.contentViewPort.SetContent(finalContent)
+
+	if renderedContent == "" {
+		return ""
+	}
+
+	return p.styleViewport()
+}
+
+func (p *permissionDialogCmp) styleViewport() string {
+	t := styles.CurrentTheme()
+	return t.S().Base.Render(p.contentViewPort.View())
+}
+
+func (p *permissionDialogCmp) render() string {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+	title := core.Title("Permission Required", p.width-4)
+	// Render header
+	headerContent := p.renderHeader()
+	// Render buttons
+	buttons := p.renderButtons()
+
+	p.contentViewPort.SetWidth(p.width - 4)
+
+	// Render content based on tool type
+	var contentFinal string
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		contentFinal = p.renderBashContent()
+	case tools.EditToolName:
+		contentFinal = p.renderEditContent()
+	case tools.PatchToolName:
+		contentFinal = p.renderPatchContent()
+	case tools.WriteToolName:
+		contentFinal = p.renderWriteContent()
+	case tools.FetchToolName:
+		contentFinal = p.renderFetchContent()
+	default:
+		contentFinal = p.renderDefaultContent()
+	}
+	// Calculate content height dynamically based on window size
+
+	content := lipgloss.JoinVertical(
+		lipgloss.Top,
+		title,
+		"",
+		headerContent,
+		contentFinal,
+		"",
+		buttons,
+		"",
+	)
+
+	return baseStyle.
+		Padding(0, 1).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus).
+		Width(p.width).
+		Render(
+			content,
+		)
+}
+
+func (p *permissionDialogCmp) View() tea.View {
+	return tea.NewView(p.render())
+}
+
+func (p *permissionDialogCmp) SetSize() tea.Cmd {
+	if p.permission.ID == "" {
+		return nil
+	}
+	switch p.permission.ToolName {
+	case tools.BashToolName:
+		p.width = int(float64(p.wWidth) * 0.4)
+		p.height = int(float64(p.wHeight) * 0.3)
+	case tools.EditToolName:
+		p.width = int(float64(p.wWidth) * 0.8)
+		p.height = int(float64(p.wHeight) * 0.8)
+	case tools.WriteToolName:
+		p.width = int(float64(p.wWidth) * 0.8)
+		p.height = int(float64(p.wHeight) * 0.8)
+	case tools.FetchToolName:
+		p.width = int(float64(p.wWidth) * 0.4)
+		p.height = int(float64(p.wHeight) * 0.3)
+	default:
+		p.width = int(float64(p.wWidth) * 0.7)
+		p.height = int(float64(p.wHeight) * 0.5)
+	}
+	return nil
+}
+
+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
+	content, err := generator()
+	if err != nil {
+		return fmt.Sprintf("Error formatting diff: %v", err)
+	}
+	return content
+}
+
+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
+	content, err := generator()
+	if err != nil {
+		return fmt.Sprintf("Error rendering markdown: %v", err)
+	}
+
+	return content
+}
+
+// ID implements PermissionDialogCmp.
+func (p *permissionDialogCmp) ID() dialogs.DialogID {
+	return PermissionsDialogID
+}
+
+// Position implements PermissionDialogCmp.
+func (p *permissionDialogCmp) Position() (int, int) {
+	row := (p.wHeight / 2) - 2 // Just a bit above the center
+	row -= p.height / 2
+	col := p.wWidth / 2
+	col -= p.width / 2
+	return row, col
+}

internal/tui/components/dialogs/quit/keys.go 🔗

@@ -0,0 +1,64 @@
+package quit
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+// KeyMap defines the keyboard bindings for the quit dialog.
+type KeyMap struct {
+	LeftRight,
+	EnterSpace,
+	Yes,
+	No,
+	Tab,
+	Close key.Binding
+}
+
+func DefaultKeymap() KeyMap {
+	return KeyMap{
+		LeftRight: key.NewBinding(
+			key.WithKeys("left", "right"),
+			key.WithHelp("←/→", "switch options"),
+		),
+		EnterSpace: key.NewBinding(
+			key.WithKeys("enter", " "),
+			key.WithHelp("enter/space", "confirm"),
+		),
+		Yes: key.NewBinding(
+			key.WithKeys("y", "Y", "ctrl+c"),
+			key.WithHelp("y/Y/ctrl+c", "yes"),
+		),
+		No: key.NewBinding(
+			key.WithKeys("n", "N"),
+			key.WithHelp("n/N", "no"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "switch options"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.LeftRight,
+		k.EnterSpace,
+	}
+}

internal/tui/components/dialogs/quit/quit.go 🔗

@@ -0,0 +1,125 @@
+package quit
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+const (
+	question                      = "Are you sure you want to quit?"
+	QuitDialogID dialogs.DialogID = "quit"
+)
+
+// QuitDialog represents a confirmation dialog for quitting the application.
+type QuitDialog interface {
+	dialogs.DialogModel
+	layout.Bindings
+}
+
+type quitDialogCmp struct {
+	wWidth  int
+	wHeight int
+
+	selectedNo bool // true if "No" button is selected
+	keymap     KeyMap
+}
+
+// NewQuitDialog creates a new quit confirmation dialog.
+func NewQuitDialog() QuitDialog {
+	return &quitDialogCmp{
+		selectedNo: true, // Default to "No" for safety
+		keymap:     DefaultKeymap(),
+	}
+}
+
+func (q *quitDialogCmp) Init() tea.Cmd {
+	return nil
+}
+
+// Update handles keyboard input for the quit dialog.
+func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		q.wWidth = msg.Width
+		q.wHeight = msg.Height
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab):
+			q.selectedNo = !q.selectedNo
+			return q, nil
+		case key.Matches(msg, q.keymap.EnterSpace):
+			if !q.selectedNo {
+				return q, tea.Quit
+			}
+			return q, util.CmdHandler(dialogs.CloseDialogMsg{})
+		case key.Matches(msg, q.keymap.Yes):
+			return q, tea.Quit
+		case key.Matches(msg, q.keymap.No, q.keymap.Close):
+			return q, util.CmdHandler(dialogs.CloseDialogMsg{})
+		}
+	}
+	return q, nil
+}
+
+// View renders the quit dialog with Yes/No buttons.
+func (q *quitDialogCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	baseStyle := t.S().Base
+	yesStyle := t.S().Text
+	noStyle := yesStyle
+
+	if q.selectedNo {
+		noStyle = noStyle.Foreground(t.White).Background(t.Secondary)
+		yesStyle = yesStyle.Background(t.BgSubtle)
+	} else {
+		yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary)
+		noStyle = noStyle.Background(t.BgSubtle)
+	}
+
+	yesButton := yesStyle.Padding(0, 1).Render("Yep!")
+	noButton := noStyle.Padding(0, 1).Render("Nope")
+
+	buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
+		lipgloss.JoinHorizontal(lipgloss.Center, yesButton, "  ", noButton),
+	)
+
+	content := baseStyle.Render(
+		lipgloss.JoinVertical(
+			lipgloss.Center,
+			question,
+			"",
+			buttons,
+		),
+	)
+
+	quitDialogStyle := baseStyle.
+		Padding(1, 2).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+
+	return tea.NewView(
+		quitDialogStyle.Render(content),
+	)
+}
+
+func (q *quitDialogCmp) BindingKeys() []key.Binding {
+	return layout.KeyMapToSlice(q.keymap)
+}
+
+func (q *quitDialogCmp) Position() (int, int) {
+	row := q.wHeight / 2
+	row -= 7 / 2
+	col := q.wWidth / 2
+	col -= (lipgloss.Width(question) + 4) / 2
+
+	return row, col
+}
+
+func (q *quitDialogCmp) ID() dialogs.DialogID {
+	return QuitDialogID
+}

internal/tui/components/dialogs/sessions/keys.go 🔗

@@ -0,0 +1,58 @@
+package sessions
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Select,
+	Next,
+	Previous,
+	Close key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Select: key.NewBinding(
+			key.WithKeys("enter", "tab", "ctrl+y"),
+			key.WithHelp("enter", "confirm"),
+		),
+		Next: key.NewBinding(
+			key.WithKeys("down", "ctrl+n"),
+			key.WithHelp("↓", "next item"),
+		),
+		Previous: key.NewBinding(
+			key.WithKeys("up", "ctrl+p"),
+			key.WithHelp("↑", "previous item"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		key.NewBinding(
+
+			key.WithKeys("down", "up"),
+			key.WithHelp("↑↓", "choose"),
+		),
+		k.Select,
+		k.Close,
+	}
+}

internal/tui/components/dialogs/sessions/sessions.go 🔗

@@ -0,0 +1,180 @@
+package sessions
+
+import (
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/chat"
+	"github.com/charmbracelet/crush/internal/tui/components/completions"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+const SessionsDialogID dialogs.DialogID = "sessions"
+
+// SessionDialog interface for the session switching dialog
+type SessionDialog interface {
+	dialogs.DialogModel
+}
+
+type sessionDialogCmp struct {
+	selectedInx       int
+	wWidth            int
+	wHeight           int
+	width             int
+	selectedSessionID string
+	keyMap            KeyMap
+	sessionsList      list.ListModel
+	renderedSelected  bool
+	help              help.Model
+}
+
+// NewSessionDialogCmp creates a new session switching dialog
+func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog {
+	t := styles.CurrentTheme()
+	listKeyMap := list.DefaultKeyMap()
+	keyMap := DefaultKeyMap()
+
+	listKeyMap.Down.SetEnabled(false)
+	listKeyMap.Up.SetEnabled(false)
+	listKeyMap.NDown.SetEnabled(false)
+	listKeyMap.NUp.SetEnabled(false)
+	listKeyMap.HalfPageDown.SetEnabled(false)
+	listKeyMap.HalfPageUp.SetEnabled(false)
+	listKeyMap.Home.SetEnabled(false)
+	listKeyMap.End.SetEnabled(false)
+
+	listKeyMap.DownOneItem = keyMap.Next
+	listKeyMap.UpOneItem = keyMap.Previous
+
+	selectedInx := 0
+	items := make([]util.Model, len(sessions))
+	if len(sessions) > 0 {
+		for i, session := range sessions {
+			items[i] = completions.NewCompletionItem(session.Title, session)
+			if session.ID == selectedID {
+				selectedInx = i
+			}
+		}
+	}
+
+	sessionsList := list.New(
+		list.WithFilterable(true),
+		list.WithFilterPlaceholder("Enter a session name"),
+		list.WithKeyMap(listKeyMap),
+		list.WithItems(items),
+		list.WithWrapNavigation(true),
+	)
+	help := help.New()
+	help.Styles = t.S().Help
+	s := &sessionDialogCmp{
+		selectedInx:       selectedInx,
+		selectedSessionID: selectedID,
+		keyMap:            DefaultKeyMap(),
+		sessionsList:      sessionsList,
+		help:              help,
+	}
+
+	return s
+}
+
+func (s *sessionDialogCmp) Init() tea.Cmd {
+	return s.sessionsList.Init()
+}
+
+func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		s.wWidth = msg.Width
+		s.wHeight = msg.Height
+		s.width = s.wWidth / 2
+		var cmds []tea.Cmd
+		cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
+		if !s.renderedSelected {
+			cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
+			s.renderedSelected = true
+		}
+		return s, tea.Sequence(cmds...)
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, s.keyMap.Select):
+			if len(s.sessionsList.Items()) > 0 {
+				items := s.sessionsList.Items()
+				selectedItemInx := s.sessionsList.SelectedIndex()
+				return s, tea.Sequence(
+					util.CmdHandler(dialogs.CloseDialogMsg{}),
+					util.CmdHandler(
+						chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)),
+					),
+				)
+			}
+		case key.Matches(msg, s.keyMap.Close):
+			return s, util.CmdHandler(dialogs.CloseDialogMsg{})
+		default:
+			u, cmd := s.sessionsList.Update(msg)
+			s.sessionsList = u.(list.ListModel)
+			return s, cmd
+		}
+	}
+	return s, nil
+}
+
+func (s *sessionDialogCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	listView := s.sessionsList.View()
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)),
+		listView.String(),
+		"",
+		t.S().Base.Width(s.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(s.help.View(s.keyMap)),
+	)
+
+	v := tea.NewView(s.style().Render(content))
+	if listView.Cursor() != nil {
+		c := s.moveCursor(listView.Cursor())
+		v.SetCursor(c)
+	}
+	return v
+}
+
+func (s *sessionDialogCmp) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+	return t.S().Base.
+		Width(s.width).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+}
+
+func (s *sessionDialogCmp) listHeight() int {
+	return s.wHeight/2 - 6 // 5 for the border, title and help
+}
+
+func (s *sessionDialogCmp) listWidth() int {
+	return s.width - 2 // 2 for the border
+}
+
+func (s *sessionDialogCmp) Position() (int, int) {
+	row := s.wHeight/4 - 2 // just a bit above the center
+	col := s.wWidth / 2
+	col -= s.width / 2
+	return row, col
+}
+
+func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
+	row, col := s.Position()
+	offset := row + 3 // Border + title
+	cursor.Y += offset
+	cursor.X = cursor.X + col + 2
+	return cursor
+}
+
+// ID implements SessionDialog.
+func (s *sessionDialogCmp) ID() dialogs.DialogID {
+	return SessionsDialogID
+}

internal/tui/components/image/image.go 🔗

@@ -0,0 +1,86 @@
+// Based on the implementation by @trashhalo at:
+// https://github.com/trashhalo/imgcat
+package image
+
+import (
+	"fmt"
+	_ "image/jpeg"
+	_ "image/png"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+)
+
+type Model struct {
+	url    string
+	image  string
+	width  uint
+	height uint
+	err    error
+}
+
+func New(width, height uint, url string) Model {
+	return Model{
+		width:  width,
+		height: height,
+		url:    url,
+	}
+}
+
+func (m Model) Init() tea.Cmd {
+	return nil
+}
+
+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case errMsg:
+		m.err = msg
+		return m, nil
+	case redrawMsg:
+		m.width = msg.width
+		m.height = msg.height
+		m.url = msg.url
+		return m, loadURL(m.url)
+	case loadMsg:
+		return handleLoadMsg(m, msg)
+	}
+	return m, nil
+}
+
+func (m Model) View() string {
+	if m.err != nil {
+		return fmt.Sprintf("couldn't load image(s): %v", m.err)
+	}
+	return m.image
+}
+
+type errMsg struct{ error }
+
+func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
+	return func() tea.Msg {
+		return redrawMsg{
+			width:  width,
+			height: height,
+			url:    url,
+		}
+	}
+}
+
+func (m Model) UpdateURL(url string) tea.Cmd {
+	return func() tea.Msg {
+		return redrawMsg{
+			width:  m.width,
+			height: m.height,
+			url:    url,
+		}
+	}
+}
+
+type redrawMsg struct {
+	width  uint
+	height uint
+	url    string
+}
+
+func (m Model) IsLoading() bool {
+	return m.image == ""
+}

internal/tui/components/image/load.go 🔗

@@ -0,0 +1,146 @@
+// Based on the implementation by @trashhalo at:
+// https://github.com/trashhalo/imgcat
+package image
+
+import (
+	"context"
+	"image"
+	"image/png"
+	"io"
+	"net/http"
+	"os"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/disintegration/imageorient"
+	"github.com/lucasb-eyer/go-colorful"
+	"github.com/muesli/termenv"
+	"github.com/nfnt/resize"
+	"github.com/srwiley/oksvg"
+	"github.com/srwiley/rasterx"
+)
+
+type loadMsg struct {
+	io.ReadCloser
+}
+
+func loadURL(url string) tea.Cmd {
+	var r io.ReadCloser
+	var err error
+
+	if strings.HasPrefix(url, "http") {
+		var resp *http.Request
+		resp, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+		r = resp.Body
+	} else {
+		r, err = os.Open(url)
+	}
+
+	if err != nil {
+		return func() tea.Msg {
+			return errMsg{err}
+		}
+	}
+
+	return load(r)
+}
+
+func load(r io.ReadCloser) tea.Cmd {
+	return func() tea.Msg {
+		return loadMsg{r}
+	}
+}
+
+func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
+	defer msg.Close()
+
+	img, err := readerToImage(m.width, m.height, m.url, msg)
+	if err != nil {
+		return m, func() tea.Msg { return errMsg{err} }
+	}
+	m.image = img
+	return m, nil
+}
+
+func imageToString(width, height uint, img image.Image) (string, error) {
+	img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
+	b := img.Bounds()
+	w := b.Max.X
+	h := b.Max.Y
+	p := termenv.ColorProfile()
+	str := strings.Builder{}
+	for y := 0; y < h; y += 2 {
+		for x := w; x < int(width); x = x + 2 {
+			str.WriteString(" ")
+		}
+		for x := range w {
+			c1, _ := colorful.MakeColor(img.At(x, y))
+			color1 := p.Color(c1.Hex())
+			c2, _ := colorful.MakeColor(img.At(x, y+1))
+			color2 := p.Color(c2.Hex())
+			str.WriteString(termenv.String("▀").
+				Foreground(color1).
+				Background(color2).
+				String())
+		}
+		str.WriteString("\n")
+	}
+	return str.String(), nil
+}
+
+func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) {
+	if strings.HasSuffix(strings.ToLower(url), ".svg") {
+		return svgToImage(width, height, r)
+	}
+
+	img, _, err := imageorient.Decode(r)
+	if err != nil {
+		return "", err
+	}
+
+	return imageToString(width, height, img)
+}
+
+func svgToImage(width uint, height uint, r io.Reader) (string, error) {
+	// Original author: https://stackoverflow.com/users/10826783/usual-human
+	// https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
+	// Adapted to use size from SVG, and to use temp file.
+
+	tmpPngFile, err := os.CreateTemp("", "img.*.png")
+	if err != nil {
+		return "", err
+	}
+	tmpPngPath := tmpPngFile.Name()
+	defer os.Remove(tmpPngPath)
+	defer tmpPngFile.Close()
+
+	// Rasterize the SVG:
+	icon, err := oksvg.ReadIconStream(r)
+	if err != nil {
+		return "", err
+	}
+	w := int(icon.ViewBox.W)
+	h := int(icon.ViewBox.H)
+	icon.SetTarget(0, 0, float64(w), float64(h))
+	rgba := image.NewRGBA(image.Rect(0, 0, w, h))
+	icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1)
+	// Write rasterized image as PNG:
+	err = png.Encode(tmpPngFile, rgba)
+	if err != nil {
+		tmpPngFile.Close()
+		return "", err
+	}
+	tmpPngFile.Close()
+
+	rPng, err := os.Open(tmpPngPath)
+	if err != nil {
+		return "", err
+	}
+	defer rPng.Close()
+
+	img, _, err := imageorient.Decode(rPng)
+	if err != nil {
+		return "", err
+	}
+	return imageToString(width, height, img)
+}

internal/tui/components/logo/logo.go 🔗

@@ -0,0 +1,313 @@
+package logo
+
+import (
+	"fmt"
+	"image/color"
+	"math/rand/v2"
+	"strings"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/slice"
+)
+
+// letterform represents a letterform. It can be stretched horizontally by
+// a given amount via the boolean argument.
+type letterform func(bool) string
+
+const diag = `╱`
+
+// Opts are the options for rendering the Crush title art.
+type Opts struct {
+	FieldColor   color.Color // diagonal lines
+	TitleColorA  color.Color // left gradient ramp point
+	TitleColorB  color.Color // right gradient ramp point
+	CharmColor   color.Color // Charm™ text color
+	VersionColor color.Color // Version text color
+}
+
+// Render renders the Crush logo. Set the argument to true to render the narrow
+// version, intended for use in a sidebar.
+//
+// The compact argument determines whether it renders compact for the sidebar
+// or wider for the main pane.
+func Render(version string, compact bool, o Opts) string {
+	const charm = " Charm™"
+
+	fg := func(c color.Color, s string) string {
+		return lipgloss.NewStyle().Foreground(c).Render(s)
+	}
+
+	// Title.
+	crush := renderWord(1, !compact, letterC, letterR, letterU, letterS, letterH)
+	crushWidth := lipgloss.Width(crush)
+	b := new(strings.Builder)
+	for r := range strings.SplitSeq(crush, "\n") {
+		fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+	}
+	crush = b.String()
+
+	// Charm and version.
+	metaRowGap := 1
+	maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
+	version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
+	gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
+	metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
+
+	// Join the meta row and big Crush title.
+	crush = strings.TrimSpace(metaRow + "\n" + crush)
+
+	// Narrow version.
+	if compact {
+		field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
+		return strings.Join([]string{field, field, crush, field, ""}, "\n")
+	}
+
+	fieldHeight := lipgloss.Height(crush)
+
+	// Left field.
+	const leftWidth = 6
+	leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
+	leftField := new(strings.Builder)
+	for range fieldHeight {
+		fmt.Fprintln(leftField, leftFieldRow)
+	}
+
+	// Right field.
+	const rightWidth = 15
+	const stepDownAt = 0
+	rightField := new(strings.Builder)
+	for i := range fieldHeight {
+		width := rightWidth
+		if i >= stepDownAt {
+			width = rightWidth - (i - stepDownAt)
+		}
+		fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
+	}
+
+	// Return the wide version.
+	const hGap = " "
+	return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
+}
+
+// renderWord renders letterforms to fork a word.
+func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string {
+	if spacing < 0 {
+		spacing = 0
+	}
+
+	renderedLetterforms := make([]string, len(letterforms))
+
+	// pick one letter randomly to stretch
+	stretchIndex := -1
+	if stretchRandomLetter {
+		stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec
+	}
+
+	for i, letter := range letterforms {
+		renderedLetterforms[i] = letter(i == stretchIndex)
+	}
+
+	if spacing > 0 {
+		// Add spaces between the letters and render.
+		renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
+	}
+	return strings.TrimSpace(
+		lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
+	)
+}
+
+// letterC renders the letter C in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterC(stretch bool) string {
+	// Here's what we're making:
+	//
+	// ▄▀▀▀▀
+	// █
+	//	▀▀▀▀
+
+	left := heredoc.Doc(`
+		▄
+		█
+	`)
+	right := heredoc.Doc(`
+		▀
+
+		▀
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(right, letterformProps{
+			stretch:    stretch,
+			width:      4,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+	)
+}
+
+// letterH renders the letter H in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterH(stretch bool) string {
+	// Here's what we're making:
+	//
+	// █   █
+	// █▀▀▀█
+	// ▀   ▀
+
+	side := heredoc.Doc(`
+		█
+		█
+		▀`)
+	middle := heredoc.Doc(`
+
+		▀
+	`)
+	return joinLetterform(
+		side,
+		stretchLetterformPart(middle, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 8,
+			maxStretch: 12,
+		}),
+		side,
+	)
+}
+
+// letterR renders the letter R in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterR(stretch bool) string {
+	// Here's what we're making:
+	//
+	// █▀▀▀▄
+	// █▀▀▀▄
+	// ▀   ▀
+
+	left := heredoc.Doc(`
+		█
+		█
+		▀
+	`)
+	center := heredoc.Doc(`
+		▀
+		▀
+	`)
+	right := heredoc.Doc(`
+		▄
+		▄
+		▀
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(center, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		right,
+	)
+}
+
+// letterS renders the letter S in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterS(stretch bool) string {
+	// Here's what we're making:
+	//
+	// ▄▀▀▀▀
+	//	▀▀▀▄
+	// ▀▀▀▀
+
+	left := heredoc.Doc(`
+		▄
+
+		▀
+	`)
+	center := heredoc.Doc(`
+		▀
+		▀
+		▀
+	`)
+	right := heredoc.Doc(`
+		▀
+		▄
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(center, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		right,
+	)
+}
+
+// letterU renders the letter U in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterU(stretch bool) string {
+	// Here's what we're making:
+	//
+	// █   █
+	// █   █
+	//	▀▀▀
+
+	side := heredoc.Doc(`
+		█
+		█
+	`)
+	middle := heredoc.Doc(`
+
+
+		▀
+	`)
+	return joinLetterform(
+		side,
+		stretchLetterformPart(middle, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		side,
+	)
+}
+
+func joinLetterform(letters ...string) string {
+	return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
+}
+
+// letterformProps defines letterform stretching properties.
+// for readability.
+type letterformProps struct {
+	width      int
+	minStretch int
+	maxStretch int
+	stretch    bool
+}
+
+// stretchLetterformPart is a helper function for letter stretching. If randomize
+// is false the minimum number will be used.
+func stretchLetterformPart(s string, p letterformProps) string {
+	if p.maxStretch < p.minStretch {
+		p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
+	}
+	n := p.width
+	if p.stretch {
+		n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
+	}
+	parts := make([]string, n)
+	for i := range parts {
+		parts[i] = s
+	}
+	return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
+}

internal/tui/components/logs/details.go 🔗

@@ -5,18 +5,18 @@ import (
 	"strings"
 	"time"
 
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/viewport"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type DetailComponent interface {
-	tea.Model
+	util.Model
 	layout.Sizeable
 	layout.Bindings
 }
@@ -50,45 +50,57 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 func (i *detailCmp) updateContent() {
 	var content strings.Builder
-	t := theme.CurrentTheme()
+	t := styles.CurrentTheme()
 
-	// Format the header with timestamp and level
-	timeStyle := lipgloss.NewStyle().Foreground(t.TextMuted())
+	if i.currentLog.ID == "" {
+		content.WriteString(t.S().Muted.Render("No log selected"))
+		i.viewport.SetContent(content.String())
+		return
+	}
+
+	// Level badge with background color
 	levelStyle := getLevelStyle(i.currentLog.Level)
+	levelBadge := levelStyle.Padding(0, 1).Render(strings.ToUpper(i.currentLog.Level))
+
+	// Timestamp with relative time
+	timeStr := i.currentLog.Time.Format("2006-01-05 15:04:05 UTC")
+	relativeTime := getRelativeTime(i.currentLog.Time)
+	timeStyle := t.S().Muted
 
+	// Header line
 	header := lipgloss.JoinHorizontal(
-		lipgloss.Center,
-		timeStyle.Render(i.currentLog.Time.Format(time.RFC3339)),
-		"  ",
-		levelStyle.Render(i.currentLog.Level),
+		lipgloss.Left,
+		timeStr,
+		" ",
+		timeStyle.Render(relativeTime),
 	)
 
-	content.WriteString(lipgloss.NewStyle().Bold(true).Render(header))
+	content.WriteString(levelBadge)
+	content.WriteString("\n\n")
+	content.WriteString(header)
 	content.WriteString("\n\n")
 
-	// Message with styling
-	messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
-	content.WriteString(messageStyle.Render("Message:"))
+	// Message section
+	messageHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true)
+	content.WriteString(messageHeaderStyle.Render("Message"))
 	content.WriteString("\n")
-	content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
+	content.WriteString(i.currentLog.Message)
 	content.WriteString("\n\n")
 
 	// Attributes section
 	if len(i.currentLog.Attributes) > 0 {
-		attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
-		content.WriteString(attrHeaderStyle.Render("Attributes:"))
+		attrHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true)
+		content.WriteString(attrHeaderStyle.Render("Attributes"))
 		content.WriteString("\n")
 
-		// Create a table-like display for attributes
-		keyStyle := lipgloss.NewStyle().Foreground(t.Primary()).Bold(true)
-		valueStyle := lipgloss.NewStyle().Foreground(t.Text())
-
 		for _, attr := range i.currentLog.Attributes {
+			keyStyle := t.S().Base.Foreground(t.Accent)
+			valueStyle := t.S().Text
 			attrLine := fmt.Sprintf("%s: %s",
 				keyStyle.Render(attr.Key),
 				valueStyle.Render(attr.Value),
 			)
-			content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine))
+			content.WriteString(attrLine)
 			content.WriteString("\n")
 		}
 	}
@@ -97,26 +109,53 @@ func (i *detailCmp) updateContent() {
 }
 
 func getLevelStyle(level string) lipgloss.Style {
-	style := lipgloss.NewStyle().Bold(true)
-	t := theme.CurrentTheme()
-	
+	t := styles.CurrentTheme()
+	style := t.S().Base.Bold(true)
+
 	switch strings.ToLower(level) {
 	case "info":
-		return style.Foreground(t.Info())
+		return style.Foreground(t.White).Background(t.Info)
 	case "warn", "warning":
-		return style.Foreground(t.Warning())
+		return style.Foreground(t.White).Background(t.Warning)
 	case "error", "err":
-		return style.Foreground(t.Error())
+		return style.Foreground(t.White).Background(t.Error)
 	case "debug":
-		return style.Foreground(t.Success())
+		return style.Foreground(t.White).Background(t.Success)
+	case "fatal":
+		return style.Foreground(t.White).Background(t.Error)
 	default:
-		return style.Foreground(t.Text())
+		return style.Foreground(t.FgBase)
+	}
+}
+
+func getRelativeTime(logTime time.Time) string {
+	now := time.Now()
+	diff := now.Sub(logTime)
+
+	if diff < time.Minute {
+		return fmt.Sprintf("%ds ago", int(diff.Seconds()))
+	} else if diff < time.Hour {
+		return fmt.Sprintf("%dm ago", int(diff.Minutes()))
+	} else if diff < 24*time.Hour {
+		return fmt.Sprintf("%dh ago", int(diff.Hours()))
+	} else if diff < 30*24*time.Hour {
+		return fmt.Sprintf("%dd ago", int(diff.Hours()/24))
+	} else if diff < 365*24*time.Hour {
+		return fmt.Sprintf("%dmo ago", int(diff.Hours()/(24*30)))
+	} else {
+		return fmt.Sprintf("%dy ago", int(diff.Hours()/(24*365)))
 	}
 }
 
-func (i *detailCmp) View() string {
-	t := theme.CurrentTheme()
-	return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), t.Background())
+func (i *detailCmp) View() tea.View {
+	t := styles.CurrentTheme()
+	style := t.S().Base.
+		BorderStyle(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus).
+		Width(i.width - 2).   // Adjust width for border
+		Height(i.height - 2). // Adjust height for border
+		Padding(1)
+	return tea.NewView(style.Render(i.viewport.View()))
 }
 
 func (i *detailCmp) GetSize() (int, int) {
@@ -124,10 +163,11 @@ func (i *detailCmp) GetSize() (int, int) {
 }
 
 func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
+	logging.Info("Setting size for detail component", "width", width, "height", height)
 	i.width = width
 	i.height = height
-	i.viewport.Width = i.width
-	i.viewport.Height = i.height
+	i.viewport.SetWidth(i.width - 4)
+	i.viewport.SetHeight(i.height - 4)
 	i.updateContent()
 	return nil
 }
@@ -138,6 +178,6 @@ func (i *detailCmp) BindingKeys() []key.Binding {
 
 func NewLogsDetails() DetailComponent {
 	return &detailCmp{
-		viewport: viewport.New(0, 0),
+		viewport: viewport.New(),
 	}
 }

internal/tui/components/logs/table.go 🔗

@@ -1,72 +1,105 @@
 package logs
 
 import (
-	"encoding/json"
+	"fmt"
 	"slices"
-
-	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/table"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/pubsub"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/table"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type TableComponent interface {
-	tea.Model
+	util.Model
 	layout.Sizeable
 	layout.Bindings
 }
 
 type tableCmp struct {
 	table table.Model
+	logs  []logging.LogMessage
 }
 
 type selectedLogMsg logging.LogMessage
 
 func (i *tableCmp) Init() tea.Cmd {
+	i.logs = logging.List()
 	i.setRows()
 	return nil
 }
 
 func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
-	switch msg.(type) {
+	switch msg := msg.(type) {
 	case pubsub.Event[logging.LogMessage]:
-		i.setRows()
-		return i, nil
+		return i, func() tea.Msg {
+			if msg.Type == pubsub.CreatedEvent {
+				rows := i.table.Rows()
+				for _, row := range rows {
+					if row[1] == msg.Payload.ID {
+						return nil // If the log already exists, do not add it again
+					}
+				}
+				i.logs = append(i.logs, msg.Payload)
+				i.table.SetRows(
+					append(
+						[]table.Row{
+							logToRow(msg.Payload),
+						},
+						i.table.Rows()...,
+					),
+				)
+			}
+			return selectedLogMsg(msg.Payload)
+		}
 	}
-	prevSelectedRow := i.table.SelectedRow()
 	t, cmd := i.table.Update(msg)
 	cmds = append(cmds, cmd)
 	i.table = t
-	selectedRow := i.table.SelectedRow()
-	if selectedRow != nil {
-		if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
-			var log logging.LogMessage
-			for _, row := range logging.List() {
-				if row.ID == selectedRow[0] {
-					log = row
-					break
-				}
-			}
-			if log.ID != "" {
-				cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
+
+	cmds = append(cmds, func() tea.Msg {
+		for _, log := range logging.List() {
+			if log.ID == i.table.SelectedRow()[1] {
+				// If the selected row matches the log ID, return the selected log message
+				return selectedLogMsg(log)
 			}
 		}
-	}
+		return nil
+	})
 	return i, tea.Batch(cmds...)
 }
 
-func (i *tableCmp) View() string {
-	t := theme.CurrentTheme()
+func (i *tableCmp) View() tea.View {
+	t := styles.CurrentTheme()
 	defaultStyles := table.DefaultStyles()
-	defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
+
+	// Header styling
+	defaultStyles.Header = defaultStyles.Header.
+		Foreground(t.Primary).
+		Bold(true).
+		BorderStyle(lipgloss.NormalBorder()).
+		BorderBottom(true).
+		BorderForeground(t.Border)
+
+	// Selected row styling
+	defaultStyles.Selected = defaultStyles.Selected.
+		Foreground(t.FgSelected).
+		Background(t.Primary).
+		Bold(false)
+
+	// Cell styling
+	defaultStyles.Cell = defaultStyles.Cell.
+		Foreground(t.FgBase)
+
 	i.table.SetStyles(defaultStyles)
-	return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), t.Background())
+	return tea.NewView(i.table.View())
 }
 
 func (i *tableCmp) GetSize() (int, int) {
@@ -76,12 +109,30 @@ func (i *tableCmp) GetSize() (int, int) {
 func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
 	i.table.SetWidth(width)
 	i.table.SetHeight(height)
-	cloumns := i.table.Columns()
-	for i, col := range cloumns {
-		col.Width = (width / len(cloumns)) - 2
-		cloumns[i] = col
-	}
-	i.table.SetColumns(cloumns)
+
+	columnWidth := (width - 10) / 4
+	i.table.SetColumns([]table.Column{
+		{
+			Title: "Level",
+			Width: 10,
+		},
+		{
+			Title: "ID",
+			Width: columnWidth,
+		},
+		{
+			Title: "Time",
+			Width: columnWidth,
+		},
+		{
+			Title: "Message",
+			Width: columnWidth,
+		},
+		{
+			Title: "Attributes",
+			Width: columnWidth,
+		},
+	})
 	return nil
 }
 
@@ -92,39 +143,54 @@ func (i *tableCmp) BindingKeys() []key.Binding {
 func (i *tableCmp) setRows() {
 	rows := []table.Row{}
 
-	logs := logging.List()
-	slices.SortFunc(logs, func(a, b logging.LogMessage) int {
+	slices.SortFunc(i.logs, func(a, b logging.LogMessage) int {
 		if a.Time.Before(b.Time) {
-			return 1
+			return -1
 		}
 		if a.Time.After(b.Time) {
-			return -1
+			return 1
 		}
 		return 0
 	})
 
-	for _, log := range logs {
-		bm, _ := json.Marshal(log.Attributes)
+	for _, log := range i.logs {
+		rows = append(rows, logToRow(log))
+	}
+	i.table.SetRows(rows)
+}
 
-		row := table.Row{
-			log.ID,
-			log.Time.Format("15:04:05"),
-			log.Level,
-			log.Message,
-			string(bm),
+func logToRow(log logging.LogMessage) table.Row {
+	// Format attributes as JSON string
+	var attrStr string
+	if len(log.Attributes) > 0 {
+		var parts []string
+		for _, attr := range log.Attributes {
+			parts = append(parts, fmt.Sprintf(`{"Key":"%s","Value":"%s"}`, attr.Key, attr.Value))
 		}
-		rows = append(rows, row)
+		attrStr = "[" + strings.Join(parts, ",") + "]"
+	}
+
+	// Format time with relative time
+	timeStr := log.Time.Format("2006-01-05 15:04:05 UTC")
+	relativeTime := getRelativeTime(log.Time)
+	fullTimeStr := timeStr + " " + relativeTime
+
+	return table.Row{
+		strings.ToUpper(log.Level),
+		log.ID,
+		fullTimeStr,
+		log.Message,
+		attrStr,
 	}
-	i.table.SetRows(rows)
 }
 
 func NewLogsTable() TableComponent {
 	columns := []table.Column{
-		{Title: "ID", Width: 4},
-		{Title: "Time", Width: 4},
-		{Title: "Level", Width: 10},
-		{Title: "Message", Width: 10},
-		{Title: "Attributes", Width: 10},
+		{Title: "Level"},
+		{Title: "ID"},
+		{Title: "Time"},
+		{Title: "Message"},
+		{Title: "Attributes"},
 	}
 
 	tableModel := table.New(

internal/tui/components/util/simple-list.go 🔗

@@ -1,159 +0,0 @@
-package utilComponents
-
-import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-)
-
-type SimpleListItem interface {
-	Render(selected bool, width int) string
-}
-
-type SimpleList[T SimpleListItem] interface {
-	tea.Model
-	layout.Bindings
-	SetMaxWidth(maxWidth int)
-	GetSelectedItem() (item T, idx int)
-	SetItems(items []T)
-	GetItems() []T
-}
-
-type simpleListCmp[T SimpleListItem] struct {
-	fallbackMsg         string
-	items               []T
-	selectedIdx         int
-	maxWidth            int
-	maxVisibleItems     int
-	useAlphaNumericKeys bool
-	width               int
-	height              int
-}
-
-type simpleListKeyMap struct {
-	Up        key.Binding
-	Down      key.Binding
-	UpAlpha   key.Binding
-	DownAlpha key.Binding
-}
-
-var simpleListKeys = simpleListKeyMap{
-	Up: key.NewBinding(
-		key.WithKeys("up"),
-		key.WithHelp("↑", "previous list item"),
-	),
-	Down: key.NewBinding(
-		key.WithKeys("down"),
-		key.WithHelp("↓", "next list item"),
-	),
-	UpAlpha: key.NewBinding(
-		key.WithKeys("k"),
-		key.WithHelp("k", "previous list item"),
-	),
-	DownAlpha: key.NewBinding(
-		key.WithKeys("j"),
-		key.WithHelp("j", "next list item"),
-	),
-}
-
-func (c *simpleListCmp[T]) Init() tea.Cmd {
-	return nil
-}
-
-func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
-			if c.selectedIdx > 0 {
-				c.selectedIdx--
-			}
-			return c, nil
-		case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
-			if c.selectedIdx < len(c.items)-1 {
-				c.selectedIdx++
-			}
-			return c, nil
-		}
-	}
-
-	return c, nil
-}
-
-func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
-	return layout.KeyMapToSlice(simpleListKeys)
-}
-
-func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
-	if len(c.items) > 0 {
-		return c.items[c.selectedIdx], c.selectedIdx
-	}
-
-	var zero T
-	return zero, -1
-}
-
-func (c *simpleListCmp[T]) SetItems(items []T) {
-	c.selectedIdx = 0
-	c.items = items
-}
-
-func (c *simpleListCmp[T]) GetItems() []T {
-	return c.items
-}
-
-func (c *simpleListCmp[T]) SetMaxWidth(width int) {
-	c.maxWidth = width
-}
-
-func (c *simpleListCmp[T]) View() string {
-	t := theme.CurrentTheme()
-	baseStyle := styles.BaseStyle()
-
-	items := c.items
-	maxWidth := c.maxWidth
-	maxVisibleItems := min(c.maxVisibleItems, len(items))
-	startIdx := 0
-
-	if len(items) <= 0 {
-		return baseStyle.
-			Background(t.Background()).
-			Padding(0, 1).
-			Width(maxWidth).
-			Render(c.fallbackMsg)
-	}
-
-	if len(items) > maxVisibleItems {
-		halfVisible := maxVisibleItems / 2
-		if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
-			startIdx = c.selectedIdx - halfVisible
-		} else if c.selectedIdx >= len(items)-halfVisible {
-			startIdx = len(items) - maxVisibleItems
-		}
-	}
-
-	endIdx := min(startIdx+maxVisibleItems, len(items))
-
-	listItems := make([]string, 0, maxVisibleItems)
-
-	for i := startIdx; i < endIdx; i++ {
-		item := items[i]
-		title := item.Render(i == c.selectedIdx, maxWidth)
-		listItems = append(listItems, title)
-	}
-
-	return lipgloss.JoinVertical(lipgloss.Left, listItems...)
-}
-
-func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
-	return &simpleListCmp[T]{
-		fallbackMsg:         fallbackMsg,
-		items:               items,
-		maxVisibleItems:     maxVisibleItems,
-		useAlphaNumericKeys: useAlphaNumericKeys,
-		selectedIdx:         0,
-	}
-}

internal/tui/image/images.go 🔗

@@ -1,72 +0,0 @@
-package image
-
-import (
-	"fmt"
-	"image"
-	"os"
-	"strings"
-
-	"github.com/charmbracelet/lipgloss"
-	"github.com/disintegration/imaging"
-	"github.com/lucasb-eyer/go-colorful"
-)
-
-func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
-	fileInfo, err := os.Stat(filePath)
-	if err != nil {
-		return false, fmt.Errorf("error getting file info: %w", err)
-	}
-
-	if fileInfo.Size() > sizeLimit {
-		return true, nil
-	}
-
-	return false, nil
-}
-
-func ToString(width int, img image.Image) string {
-	img = imaging.Resize(img, width, 0, imaging.Lanczos)
-	b := img.Bounds()
-	imageWidth := b.Max.X
-	h := b.Max.Y
-	str := strings.Builder{}
-
-	for heightCounter := 0; heightCounter < h; heightCounter += 2 {
-		for x := range imageWidth {
-			c1, _ := colorful.MakeColor(img.At(x, heightCounter))
-			color1 := lipgloss.Color(c1.Hex())
-
-			var color2 lipgloss.Color
-			if heightCounter+1 < h {
-				c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
-				color2 = lipgloss.Color(c2.Hex())
-			} else {
-				color2 = color1
-			}
-
-			str.WriteString(lipgloss.NewStyle().Foreground(color1).
-				Background(color2).Render("▀"))
-		}
-
-		str.WriteString("\n")
-	}
-
-	return str.String()
-}
-
-func ImagePreview(width int, filename string) (string, error) {
-	imageContent, err := os.Open(filename)
-	if err != nil {
-		return "", err
-	}
-	defer imageContent.Close()
-
-	img, _, err := image.Decode(imageContent)
-	if err != nil {
-		return "", err
-	}
-
-	imageString := ToString(width, img)
-
-	return imageString, nil
-}

internal/tui/keys.go 🔗

@@ -0,0 +1,56 @@
+package tui
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Logs     key.Binding
+	Quit     key.Binding
+	Help     key.Binding
+	Commands key.Binding
+	Sessions key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Logs: key.NewBinding(
+			key.WithKeys("ctrl+l"),
+			key.WithHelp("ctrl+l", "logs"),
+		),
+		Quit: key.NewBinding(
+			key.WithKeys("ctrl+c"),
+			key.WithHelp("ctrl+c", "quit"),
+		),
+
+		Help: key.NewBinding(
+			key.WithKeys("ctrl+_"),
+			key.WithHelp("ctrl+?", "toggle help"),
+		),
+		Commands: key.NewBinding(
+			key.WithKeys("ctrl+p"),
+			key.WithHelp("ctrl+p", "commands"),
+		),
+		Sessions: key.NewBinding(
+			key.WithKeys("ctrl+s"),
+			key.WithHelp("ctrl+s", "sessions"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{}
+}

internal/tui/layout/container.go 🔗

@@ -1,22 +1,28 @@
 package layout
 
 import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type Container interface {
-	tea.Model
+	util.Model
 	Sizeable
 	Bindings
+	Positionable
+	Focusable
 }
 type container struct {
-	width  int
-	height int
+	width     int
+	height    int
+	isFocused bool
 
-	content tea.Model
+	x, y int
+
+	content util.Model
 
 	// Style options
 	paddingTop    int
@@ -31,23 +37,47 @@ type container struct {
 	borderStyle  lipgloss.Border
 }
 
+type ContainerOption func(*container)
+
+func NewContainer(content util.Model, options ...ContainerOption) Container {
+	c := &container{
+		content:     content,
+		borderStyle: lipgloss.NormalBorder(),
+	}
+
+	for _, option := range options {
+		option(c)
+	}
+
+	return c
+}
+
 func (c *container) Init() tea.Cmd {
 	return c.content.Init()
 }
 
 func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	u, cmd := c.content.Update(msg)
-	c.content = u
-	return c, cmd
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		if c.IsFocused() {
+			u, cmd := c.content.Update(msg)
+			c.content = u.(util.Model)
+			return c, cmd
+		}
+		return c, nil
+	default:
+		u, cmd := c.content.Update(msg)
+		c.content = u.(util.Model)
+		return c, cmd
+	}
 }
 
-func (c *container) View() string {
-	t := theme.CurrentTheme()
-	style := lipgloss.NewStyle()
+func (c *container) View() tea.View {
+	t := styles.CurrentTheme()
 	width := c.width
 	height := c.height
 
-	style = style.Background(t.Background())
+	style := t.S().Base
 
 	// Apply border if any side is enabled
 	if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
@@ -65,7 +95,7 @@ func (c *container) View() string {
 			width--
 		}
 		style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
-		style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal())
+		style = style.BorderBackground(t.BgBase).BorderForeground(t.Border)
 	}
 	style = style.
 		Width(width).
@@ -75,7 +105,11 @@ func (c *container) View() string {
 		PaddingBottom(c.paddingBottom).
 		PaddingLeft(c.paddingLeft)
 
-	return style.Render(c.content.View())
+	contentView := c.content.View()
+	view := tea.NewView(style.Render(contentView.String()))
+	cursor := contentView.Cursor()
+	view.SetCursor(cursor)
+	return view
 }
 
 func (c *container) SetSize(width, height int) tea.Cmd {
@@ -114,6 +148,15 @@ func (c *container) GetSize() (int, int) {
 	return c.width, c.height
 }
 
+func (c *container) SetPosition(x, y int) tea.Cmd {
+	c.x = x
+	c.y = y
+	if positionable, ok := c.content.(Positionable); ok {
+		return positionable.SetPosition(x, y)
+	}
+	return nil
+}
+
 func (c *container) BindingKeys() []key.Binding {
 	if b, ok := c.content.(Bindings); ok {
 		return b.BindingKeys()
@@ -121,20 +164,31 @@ func (c *container) BindingKeys() []key.Binding {
 	return []key.Binding{}
 }
 
-type ContainerOption func(*container)
-
-func NewContainer(content tea.Model, options ...ContainerOption) Container {
-
-	c := &container{
-		content:     content,
-		borderStyle: lipgloss.NormalBorder(),
+// Blur implements Container.
+func (c *container) Blur() tea.Cmd {
+	c.isFocused = false
+	if focusable, ok := c.content.(Focusable); ok {
+		return focusable.Blur()
 	}
+	return nil
+}
 
-	for _, option := range options {
-		option(c)
+// Focus implements Container.
+func (c *container) Focus() tea.Cmd {
+	c.isFocused = true
+	if focusable, ok := c.content.(Focusable); ok {
+		return focusable.Focus()
 	}
+	return nil
+}
 
-	return c
+// IsFocused implements Container.
+func (c *container) IsFocused() bool {
+	isFocused := c.isFocused
+	if focusable, ok := c.content.(Focusable); ok {
+		isFocused = isFocused || focusable.IsFocused()
+	}
+	return isFocused
 }
 
 // Padding options

internal/tui/layout/layout.go 🔗

@@ -3,8 +3,9 @@ package layout
 import (
 	"reflect"
 
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
 )
 
 type Focusable interface {
@@ -22,6 +23,14 @@ type Bindings interface {
 	BindingKeys() []key.Binding
 }
 
+type Positionable interface {
+	SetPosition(x, y int) tea.Cmd
+}
+
+type Help interface {
+	Help() help.KeyMap
+}
+
 func KeyMapToSlice(t any) (bindings []key.Binding) {
 	typ := reflect.TypeOf(t)
 	if typ.Kind() != reflect.Struct {

internal/tui/layout/overlay.go 🔗

@@ -1,169 +0,0 @@
-package layout
-
-import (
-	"strings"
-
-	"github.com/charmbracelet/lipgloss"
-	chAnsi "github.com/charmbracelet/x/ansi"
-	"github.com/muesli/ansi"
-	"github.com/muesli/reflow/truncate"
-	"github.com/muesli/termenv"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-// Most of this code is borrowed from
-// https://github.com/charmbracelet/lipgloss/pull/102
-// as well as the lipgloss library, with some modification for what I needed.
-
-// Split a string into lines, additionally returning the size of the widest
-// line.
-func getLines(s string) (lines []string, widest int) {
-	lines = strings.Split(s, "\n")
-
-	for _, l := range lines {
-		w := ansi.PrintableRuneWidth(l)
-		if widest < w {
-			widest = w
-		}
-	}
-
-	return lines, widest
-}
-
-// PlaceOverlay places fg on top of bg.
-func PlaceOverlay(
-	x, y int,
-	fg, bg string,
-	shadow bool, opts ...WhitespaceOption,
-) string {
-	fgLines, fgWidth := getLines(fg)
-	bgLines, bgWidth := getLines(bg)
-	bgHeight := len(bgLines)
-	fgHeight := len(fgLines)
-
-	if shadow {
-		t := theme.CurrentTheme()
-		baseStyle := styles.BaseStyle()
-
-		var shadowbg string = ""
-		shadowchar := lipgloss.NewStyle().
-			Background(t.BackgroundDarker()).
-			Foreground(t.Background()).
-			Render("░")
-		bgchar := baseStyle.Render(" ")
-		for i := 0; i <= fgHeight; i++ {
-			if i == 0 {
-				shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"
-			} else {
-				shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n"
-			}
-		}
-
-		fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...)
-		fgLines, fgWidth = getLines(fg)
-		fgHeight = len(fgLines)
-	}
-
-	if fgWidth >= bgWidth && fgHeight >= bgHeight {
-		// FIXME: return fg or bg?
-		return fg
-	}
-	// TODO: allow placement outside of the bg box?
-	x = util.Clamp(x, 0, bgWidth-fgWidth)
-	y = util.Clamp(y, 0, bgHeight-fgHeight)
-
-	ws := &whitespace{}
-	for _, opt := range opts {
-		opt(ws)
-	}
-
-	var b strings.Builder
-	for i, bgLine := range bgLines {
-		if i > 0 {
-			b.WriteByte('\n')
-		}
-		if i < y || i >= y+fgHeight {
-			b.WriteString(bgLine)
-			continue
-		}
-
-		pos := 0
-		if x > 0 {
-			left := truncate.String(bgLine, uint(x))
-			pos = ansi.PrintableRuneWidth(left)
-			b.WriteString(left)
-			if pos < x {
-				b.WriteString(ws.render(x - pos))
-				pos = x
-			}
-		}
-
-		fgLine := fgLines[i-y]
-		b.WriteString(fgLine)
-		pos += ansi.PrintableRuneWidth(fgLine)
-
-		right := cutLeft(bgLine, pos)
-		bgWidth := ansi.PrintableRuneWidth(bgLine)
-		rightWidth := ansi.PrintableRuneWidth(right)
-		if rightWidth <= bgWidth-pos {
-			b.WriteString(ws.render(bgWidth - rightWidth - pos))
-		}
-
-		b.WriteString(right)
-	}
-
-	return b.String()
-}
-
-// cutLeft cuts printable characters from the left.
-// This function is heavily based on muesli's ansi and truncate packages.
-func cutLeft(s string, cutWidth int) string {
-	return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
-}
-
-func max(a, b int) int {
-	if a > b {
-		return a
-	}
-	return b
-}
-
-type whitespace struct {
-	style termenv.Style
-	chars string
-}
-
-// Render whitespaces.
-func (w whitespace) render(width int) string {
-	if w.chars == "" {
-		w.chars = " "
-	}
-
-	r := []rune(w.chars)
-	j := 0
-	b := strings.Builder{}
-
-	// Cycle through runes and print them into the whitespace.
-	for i := 0; i < width; {
-		b.WriteRune(r[j])
-		j++
-		if j >= len(r) {
-			j = 0
-		}
-		i += ansi.PrintableRuneWidth(string(r[j]))
-	}
-
-	// Fill any extra gaps white spaces. This might be necessary if any runes
-	// are more than one cell wide, which could leave a one-rune gap.
-	short := width - ansi.PrintableRuneWidth(b.String())
-	if short > 0 {
-		b.WriteString(strings.Repeat(" ", short))
-	}
-
-	return w.style.Styled(b.String())
-}
-
-// WhitespaceOption sets a styling rule for rendering whitespace.
-type WhitespaceOption func(*whitespace)

internal/tui/layout/split.go 🔗

@@ -1,14 +1,23 @@
 package layout
 
 import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+type LayoutPanel string
+
+const (
+	LeftPanel   LayoutPanel = "left"
+	RightPanel  LayoutPanel = "right"
+	BottomPanel LayoutPanel = "bottom"
 )
 
 type SplitPaneLayout interface {
-	tea.Model
+	util.Model
 	Sizeable
 	Bindings
 	SetLeftPanel(panel Container) tea.Cmd
@@ -18,17 +27,23 @@ type SplitPaneLayout interface {
 	ClearLeftPanel() tea.Cmd
 	ClearRightPanel() tea.Cmd
 	ClearBottomPanel() tea.Cmd
+
+	FocusPanel(panel LayoutPanel) tea.Cmd
 }
 
 type splitPaneLayout struct {
-	width         int
-	height        int
+	width  int
+	height int
+
 	ratio         float64
 	verticalRatio float64
 
 	rightPanel  Container
 	leftPanel   Container
 	bottomPanel Container
+
+	fixedBottomHeight int // Fixed height for the bottom panel, if any
+	fixedRightWidth   int // Fixed width for the right panel, if any
 }
 
 type SplitPaneOption func(*splitPaneLayout)
@@ -85,17 +100,17 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return s, tea.Batch(cmds...)
 }
 
-func (s *splitPaneLayout) View() string {
+func (s *splitPaneLayout) View() tea.View {
 	var topSection string
 
 	if s.leftPanel != nil && s.rightPanel != nil {
 		leftView := s.leftPanel.View()
 		rightView := s.rightPanel.View()
-		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
+		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
 	} else if s.leftPanel != nil {
-		topSection = s.leftPanel.View()
+		topSection = s.leftPanel.View().String()
 	} else if s.rightPanel != nil {
-		topSection = s.rightPanel.View()
+		topSection = s.rightPanel.View().String()
 	} else {
 		topSection = ""
 	}
@@ -104,25 +119,32 @@ func (s *splitPaneLayout) View() string {
 
 	if s.bottomPanel != nil && topSection != "" {
 		bottomView := s.bottomPanel.View()
-		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
+		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
 	} else if s.bottomPanel != nil {
-		finalView = s.bottomPanel.View()
+		finalView = s.bottomPanel.View().String()
 	} else {
 		finalView = topSection
 	}
 
-	if finalView != "" {
-		t := theme.CurrentTheme()
+	// TODO: think of a better way to handle multiple cursors
+	var cursor *tea.Cursor
+	if s.bottomPanel != nil {
+		cursor = s.bottomPanel.View().Cursor()
+	} else if s.rightPanel != nil {
+		cursor = s.rightPanel.View().Cursor()
+	} else if s.leftPanel != nil {
+		cursor = s.leftPanel.View().Cursor()
+	}
 
-		style := lipgloss.NewStyle().
-			Width(s.width).
-			Height(s.height).
-			Background(t.Background())
+	t := styles.CurrentTheme()
 
-		return style.Render(finalView)
-	}
+	style := t.S().Base.
+		Width(s.width).
+		Height(s.height)
 
-	return finalView
+	view := tea.NewView(style.Render(finalView))
+	view.SetCursor(cursor)
+	return view
 }
 
 func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
@@ -130,9 +152,19 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 	s.height = height
 
 	var topHeight, bottomHeight int
+	var cmds []tea.Cmd
 	if s.bottomPanel != nil {
-		topHeight = int(float64(height) * s.verticalRatio)
-		bottomHeight = height - topHeight
+		if s.fixedBottomHeight > 0 {
+			bottomHeight = s.fixedBottomHeight
+			topHeight = height - bottomHeight
+		} else {
+			topHeight = int(float64(height) * s.verticalRatio)
+			bottomHeight = height - topHeight
+			if bottomHeight <= 0 {
+				bottomHeight = 2
+				topHeight = height - bottomHeight
+			}
+		}
 	} else {
 		topHeight = height
 		bottomHeight = 0
@@ -140,8 +172,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 
 	var leftWidth, rightWidth int
 	if s.leftPanel != nil && s.rightPanel != nil {
-		leftWidth = int(float64(width) * s.ratio)
-		rightWidth = width - leftWidth
+		if s.fixedRightWidth > 0 {
+			rightWidth = s.fixedRightWidth
+			leftWidth = width - rightWidth
+		} else {
+			leftWidth = int(float64(width) * s.ratio)
+			rightWidth = width - leftWidth
+			if rightWidth <= 0 {
+				rightWidth = 2
+				leftWidth = width - rightWidth
+			}
+		}
 	} else if s.leftPanel != nil {
 		leftWidth = width
 		rightWidth = 0
@@ -150,20 +191,28 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 		rightWidth = width
 	}
 
-	var cmds []tea.Cmd
 	if s.leftPanel != nil {
 		cmd := s.leftPanel.SetSize(leftWidth, topHeight)
 		cmds = append(cmds, cmd)
+		if positionable, ok := s.leftPanel.(Positionable); ok {
+			cmds = append(cmds, positionable.SetPosition(0, 0))
+		}
 	}
 
 	if s.rightPanel != nil {
 		cmd := s.rightPanel.SetSize(rightWidth, topHeight)
 		cmds = append(cmds, cmd)
+		if positionable, ok := s.rightPanel.(Positionable); ok {
+			cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
+		}
 	}
 
 	if s.bottomPanel != nil {
 		cmd := s.bottomPanel.SetSize(width, bottomHeight)
 		cmds = append(cmds, cmd)
+		if positionable, ok := s.bottomPanel.(Positionable); ok {
+			cmds = append(cmds, positionable.SetPosition(0, topHeight))
+		}
 	}
 	return tea.Batch(cmds...)
 }
@@ -240,11 +289,30 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
 	return keys
 }
 
-func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
+func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
+	panels := map[LayoutPanel]Container{
+		LeftPanel:   s.leftPanel,
+		RightPanel:  s.rightPanel,
+		BottomPanel: s.bottomPanel,
+	}
+	var cmds []tea.Cmd
+	for p, container := range panels {
+		if container == nil {
+			continue
+		}
+		if p == panel {
+			cmds = append(cmds, container.Focus())
+		} else {
+			cmds = append(cmds, container.Blur())
+		}
+	}
+	return tea.Batch(cmds...)
+}
 
+func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
 	layout := &splitPaneLayout{
-		ratio:         0.7,
-		verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
+		ratio:         0.8,
+		verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
 	}
 	for _, option := range options {
 		option(layout)
@@ -281,3 +349,15 @@ func WithVerticalRatio(ratio float64) SplitPaneOption {
 		s.verticalRatio = ratio
 	}
 }
+
+func WithFixedBottomHeight(height int) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.fixedBottomHeight = height
+	}
+}
+
+func WithFixedRightWidth(width int) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.fixedRightWidth = width
+	}
+}

internal/tui/page/chat.go 🔗

@@ -1,237 +0,0 @@
-package page
-
-import (
-	"context"
-	"strings"
-
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/completions"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/components/chat"
-	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-var ChatPage PageID = "chat"
-
-type chatPage struct {
-	app                  *app.App
-	editor               layout.Container
-	messages             layout.Container
-	layout               layout.SplitPaneLayout
-	session              session.Session
-	completionDialog     dialog.CompletionDialog
-	showCompletionDialog bool
-}
-
-type ChatKeyMap struct {
-	ShowCompletionDialog key.Binding
-	NewSession           key.Binding
-	Cancel               key.Binding
-}
-
-var keyMap = ChatKeyMap{
-	ShowCompletionDialog: key.NewBinding(
-		key.WithKeys("@"),
-		key.WithHelp("@", "Complete"),
-	),
-	NewSession: key.NewBinding(
-		key.WithKeys("ctrl+n"),
-		key.WithHelp("ctrl+n", "new session"),
-	),
-	Cancel: key.NewBinding(
-		key.WithKeys("esc"),
-		key.WithHelp("esc", "cancel"),
-	),
-}
-
-func (p *chatPage) Init() tea.Cmd {
-	cmds := []tea.Cmd{
-		p.layout.Init(),
-		p.completionDialog.Init(),
-	}
-	return tea.Batch(cmds...)
-}
-
-func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		cmd := p.layout.SetSize(msg.Width, msg.Height)
-		cmds = append(cmds, cmd)
-	case dialog.CompletionDialogCloseMsg:
-		p.showCompletionDialog = false
-	case chat.SendMsg:
-		cmd := p.sendMessage(msg.Text, msg.Attachments)
-		if cmd != nil {
-			return p, cmd
-		}
-	case dialog.CommandRunCustomMsg:
-		// Check if the agent is busy before executing custom commands
-		if p.app.CoderAgent.IsBusy() {
-			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
-		}
-		
-		// Process the command content with arguments if any
-		content := msg.Content
-		if msg.Args != nil {
-			// Replace all named arguments with their values
-			for name, value := range msg.Args {
-				placeholder := "$" + name
-				content = strings.ReplaceAll(content, placeholder, value)
-			}
-		}
-		
-		// Handle custom command execution
-		cmd := p.sendMessage(content, nil)
-		if cmd != nil {
-			return p, cmd
-		}
-	case chat.SessionSelectedMsg:
-		if p.session.ID == "" {
-			cmd := p.setSidebar()
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		}
-		p.session = msg
-	case tea.KeyMsg:
-		switch {
-		case key.Matches(msg, keyMap.ShowCompletionDialog):
-			p.showCompletionDialog = true
-			// Continue sending keys to layout->chat
-		case key.Matches(msg, keyMap.NewSession):
-			p.session = session.Session{}
-			return p, tea.Batch(
-				p.clearSidebar(),
-				util.CmdHandler(chat.SessionClearedMsg{}),
-			)
-		case key.Matches(msg, keyMap.Cancel):
-			if p.session.ID != "" {
-				// Cancel the current session's generation process
-				// This allows users to interrupt long-running operations
-				p.app.CoderAgent.Cancel(p.session.ID)
-				return p, nil
-			}
-		}
-	}
-	if p.showCompletionDialog {
-		context, contextCmd := p.completionDialog.Update(msg)
-		p.completionDialog = context.(dialog.CompletionDialog)
-		cmds = append(cmds, contextCmd)
-
-		// Doesn't forward event if enter key is pressed
-		if keyMsg, ok := msg.(tea.KeyMsg); ok {
-			if keyMsg.String() == "enter" {
-				return p, tea.Batch(cmds...)
-			}
-		}
-	}
-
-	u, cmd := p.layout.Update(msg)
-	cmds = append(cmds, cmd)
-	p.layout = u.(layout.SplitPaneLayout)
-
-	return p, tea.Batch(cmds...)
-}
-
-func (p *chatPage) setSidebar() tea.Cmd {
-	sidebarContainer := layout.NewContainer(
-		chat.NewSidebarCmp(p.session, p.app.History),
-		layout.WithPadding(1, 1, 1, 1),
-	)
-	return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
-}
-
-func (p *chatPage) clearSidebar() tea.Cmd {
-	return p.layout.ClearRightPanel()
-}
-
-func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
-	var cmds []tea.Cmd
-	if p.session.ID == "" {
-		session, err := p.app.Sessions.Create(context.Background(), "New Session")
-		if err != nil {
-			return util.ReportError(err)
-		}
-
-		p.session = session
-		cmd := p.setSidebar()
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
-	}
-
-	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
-	if err != nil {
-		return util.ReportError(err)
-	}
-	return tea.Batch(cmds...)
-}
-
-func (p *chatPage) SetSize(width, height int) tea.Cmd {
-	return p.layout.SetSize(width, height)
-}
-
-func (p *chatPage) GetSize() (int, int) {
-	return p.layout.GetSize()
-}
-
-func (p *chatPage) View() string {
-	layoutView := p.layout.View()
-
-	if p.showCompletionDialog {
-		_, layoutHeight := p.layout.GetSize()
-		editorWidth, editorHeight := p.editor.GetSize()
-
-		p.completionDialog.SetWidth(editorWidth)
-		overlay := p.completionDialog.View()
-
-		layoutView = layout.PlaceOverlay(
-			0,
-			layoutHeight-editorHeight-lipgloss.Height(overlay),
-			overlay,
-			layoutView,
-			false,
-		)
-	}
-
-	return layoutView
-}
-
-func (p *chatPage) BindingKeys() []key.Binding {
-	bindings := layout.KeyMapToSlice(keyMap)
-	bindings = append(bindings, p.messages.BindingKeys()...)
-	bindings = append(bindings, p.editor.BindingKeys()...)
-	return bindings
-}
-
-func NewChatPage(app *app.App) tea.Model {
-	cg := completions.NewFileAndFolderContextGroup()
-	completionDialog := dialog.NewCompletionDialogCmp(cg)
-
-	messagesContainer := layout.NewContainer(
-		chat.NewMessagesCmp(app),
-		layout.WithPadding(1, 1, 0, 1),
-	)
-	editorContainer := layout.NewContainer(
-		chat.NewEditorCmp(app),
-		layout.WithBorder(true, false, false, false),
-	)
-	return &chatPage{
-		app:              app,
-		editor:           editorContainer,
-		messages:         messagesContainer,
-		completionDialog: completionDialog,
-		layout: layout.NewSplitPane(
-			layout.WithLeftPanel(messagesContainer),
-			layout.WithBottomPanel(editorContainer),
-		),
-	}
-}

internal/tui/page/chat/chat.go 🔗

@@ -0,0 +1,194 @@
+package chat
+
+import (
+	"context"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/chat"
+	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
+	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/page"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+var ChatPage page.PageID = "chat"
+
+type ChatFocusedMsg struct {
+	Focused bool // True if the chat input is focused, false otherwise
+}
+
+type (
+	OpenFilePickerMsg struct{}
+	chatPage          struct {
+		app *app.App
+
+		layout layout.SplitPaneLayout
+
+		session session.Session
+
+		keyMap KeyMap
+
+		chatFocused bool
+	}
+)
+
+func (p *chatPage) Init() tea.Cmd {
+	cmd := p.layout.Init()
+	return tea.Batch(
+		cmd,
+		p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor)
+	)
+}
+
+func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		cmd := p.layout.SetSize(msg.Width, msg.Height)
+		cmds = append(cmds, cmd)
+	case chat.SendMsg:
+		cmd := p.sendMessage(msg.Text, msg.Attachments)
+		if cmd != nil {
+			return p, cmd
+		}
+	case commands.CommandRunCustomMsg:
+		// Check if the agent is busy before executing custom commands
+		if p.app.CoderAgent.IsBusy() {
+			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
+		}
+
+		// Handle custom command execution
+		cmd := p.sendMessage(msg.Content, nil)
+		if cmd != nil {
+			return p, cmd
+		}
+	case chat.SessionSelectedMsg:
+		if p.session.ID == "" {
+			cmd := p.setMessages()
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+		p.session = msg
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, p.keyMap.NewSession):
+			p.session = session.Session{}
+			return p, tea.Batch(
+				p.clearMessages(),
+				util.CmdHandler(chat.SessionClearedMsg{}),
+			)
+
+		case key.Matches(msg, p.keyMap.FilePicker):
+			cfg := config.Get()
+			agentCfg := cfg.Agents[config.AgentCoder]
+			selectedModelID := agentCfg.Model
+			model := models.SupportedModels[selectedModelID]
+			if model.SupportsAttachments {
+				return p, util.CmdHandler(OpenFilePickerMsg{})
+			} else {
+				return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
+			}
+		case key.Matches(msg, p.keyMap.Tab):
+			if p.session.ID == "" {
+				return p, nil
+			}
+			p.chatFocused = !p.chatFocused
+			if p.chatFocused {
+				cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
+				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
+			} else {
+				cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
+				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
+			}
+			return p, tea.Batch(cmds...)
+		case key.Matches(msg, p.keyMap.Cancel):
+			if p.session.ID != "" {
+				// Cancel the current session's generation process
+				// This allows users to interrupt long-running operations
+				p.app.CoderAgent.Cancel(p.session.ID)
+				return p, nil
+			}
+		}
+	}
+	u, cmd := p.layout.Update(msg)
+	cmds = append(cmds, cmd)
+	p.layout = u.(layout.SplitPaneLayout)
+
+	return p, tea.Batch(cmds...)
+}
+
+func (p *chatPage) setMessages() tea.Cmd {
+	messagesContainer := layout.NewContainer(
+		chat.NewMessagesListCmp(p.app),
+		layout.WithPadding(1, 1, 0, 1),
+	)
+	return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
+}
+
+func (p *chatPage) clearMessages() tea.Cmd {
+	return p.layout.ClearLeftPanel()
+}
+
+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
+	var cmds []tea.Cmd
+	if p.session.ID == "" {
+		session, err := p.app.Sessions.Create(context.Background(), "New Session")
+		if err != nil {
+			return util.ReportError(err)
+		}
+
+		p.session = session
+		cmd := p.setMessages()
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
+	}
+
+	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
+	if err != nil {
+		return util.ReportError(err)
+	}
+	return tea.Batch(cmds...)
+}
+
+func (p *chatPage) SetSize(width, height int) tea.Cmd {
+	return p.layout.SetSize(width, height)
+}
+
+func (p *chatPage) GetSize() (int, int) {
+	return p.layout.GetSize()
+}
+
+func (p *chatPage) View() tea.View {
+	return p.layout.View()
+}
+
+func NewChatPage(app *app.App) util.Model {
+	sidebarContainer := layout.NewContainer(
+		sidebar.NewSidebarCmp(),
+		layout.WithPadding(1, 1, 1, 1),
+	)
+	editorContainer := layout.NewContainer(
+		editor.NewEditorCmp(app),
+	)
+	return &chatPage{
+		app: app,
+		layout: layout.NewSplitPane(
+			layout.WithRightPanel(sidebarContainer),
+			layout.WithBottomPanel(editorContainer),
+			layout.WithFixedBottomHeight(5),
+			layout.WithFixedRightWidth(31),
+		),
+		keyMap: DefaultKeyMap(),
+	}
+}

internal/tui/page/chat/keys.go 🔗

@@ -0,0 +1,52 @@
+package chat
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	NewSession key.Binding
+	FilePicker key.Binding
+	Cancel     key.Binding
+	Tab        key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		NewSession: key.NewBinding(
+			key.WithKeys("ctrl+n"),
+			key.WithHelp("ctrl+n", "new session"),
+		),
+		Cancel: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "change focus"),
+		),
+		FilePicker: key.NewBinding(
+			key.WithKeys("ctrl+f"),
+			key.WithHelp("ctrl+f", "select files to upload"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Tab,
+	}
+}

internal/tui/page/logs.go 🔗

@@ -1,83 +0,0 @@
-package page
-
-import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/tui/components/logs"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/styles"
-)
-
-var LogsPage PageID = "logs"
-
-type LogPage interface {
-	tea.Model
-	layout.Sizeable
-	layout.Bindings
-}
-type logsPage struct {
-	width, height int
-	table         layout.Container
-	details       layout.Container
-}
-
-func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		p.width = msg.Width
-		p.height = msg.Height
-		return p, p.SetSize(msg.Width, msg.Height)
-	}
-
-	table, cmd := p.table.Update(msg)
-	cmds = append(cmds, cmd)
-	p.table = table.(layout.Container)
-	details, cmd := p.details.Update(msg)
-	cmds = append(cmds, cmd)
-	p.details = details.(layout.Container)
-
-	return p, tea.Batch(cmds...)
-}
-
-func (p *logsPage) View() string {
-	style := styles.BaseStyle().Width(p.width).Height(p.height)
-	return style.Render(lipgloss.JoinVertical(lipgloss.Top,
-		p.table.View(),
-		p.details.View(),
-	))
-}
-
-func (p *logsPage) BindingKeys() []key.Binding {
-	return p.table.BindingKeys()
-}
-
-// GetSize implements LogPage.
-func (p *logsPage) GetSize() (int, int) {
-	return p.width, p.height
-}
-
-// SetSize implements LogPage.
-func (p *logsPage) SetSize(width int, height int) tea.Cmd {
-	p.width = width
-	p.height = height
-	return tea.Batch(
-		p.table.SetSize(width, height/2),
-		p.details.SetSize(width, height/2),
-	)
-}
-
-func (p *logsPage) Init() tea.Cmd {
-	return tea.Batch(
-		p.table.Init(),
-		p.details.Init(),
-	)
-}
-
-func NewLogsPage() LogPage {
-	return &logsPage{
-		table:   layout.NewContainer(logs.NewLogsTable(), layout.WithBorderAll()),
-		details: layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderAll()),
-	}
-}

internal/tui/page/logs/keys.go 🔗

@@ -0,0 +1,37 @@
+package logs
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+)
+
+type KeyMap struct {
+	Back key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Back: key.NewBinding(
+			key.WithKeys("esc", "backspace"),
+			key.WithHelp("esc/backspace", "back to chat"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := layout.KeyMapToSlice(k)
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Back,
+	}
+}

internal/tui/page/logs/logs.go 🔗

@@ -0,0 +1,100 @@
+package logs
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	logsComponents "github.com/charmbracelet/crush/internal/tui/components/logs"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/page"
+	"github.com/charmbracelet/crush/internal/tui/page/chat"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+var LogsPage page.PageID = "logs"
+
+type LogPage interface {
+	util.Model
+	layout.Sizeable
+}
+
+type logsPage struct {
+	width, height int
+	table         logsComponents.TableComponent
+	details       logsComponents.DetailComponent
+	keyMap        KeyMap
+}
+
+func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		p.width = msg.Width
+		p.height = msg.Height
+		return p, p.SetSize(msg.Width, msg.Height)
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, p.keyMap.Back):
+			return p, util.CmdHandler(page.PageChangeMsg{ID: chat.ChatPage})
+		}
+	}
+
+	table, cmd := p.table.Update(msg)
+	cmds = append(cmds, cmd)
+	p.table = table.(logsComponents.TableComponent)
+	details, cmd := p.details.Update(msg)
+	cmds = append(cmds, cmd)
+	p.details = details.(logsComponents.DetailComponent)
+
+	return p, tea.Batch(cmds...)
+}
+
+func (p *logsPage) View() tea.View {
+	baseStyle := styles.CurrentTheme().S().Base
+	style := baseStyle.Width(p.width).Height(p.height).Padding(1)
+	title := core.Title("Logs", p.width-2)
+
+	return tea.NewView(
+		style.Render(
+			lipgloss.JoinVertical(lipgloss.Top,
+				title,
+				p.details.View().String(),
+				p.table.View().String(),
+			),
+		),
+	)
+}
+
+// GetSize implements LogPage.
+func (p *logsPage) GetSize() (int, int) {
+	return p.width, p.height
+}
+
+// SetSize implements LogPage.
+func (p *logsPage) SetSize(width int, height int) tea.Cmd {
+	p.width = width
+	p.height = height
+	availableHeight := height - 2 // Padding for top and bottom
+	availableHeight -= 1          // title height
+	return tea.Batch(
+		p.table.SetSize(width-2, availableHeight/2),
+		p.details.SetSize(width-2, availableHeight/2),
+	)
+}
+
+func (p *logsPage) Init() tea.Cmd {
+	return tea.Batch(
+		p.table.Init(),
+		p.details.Init(),
+	)
+}
+
+func NewLogsPage() LogPage {
+	return &logsPage{
+		details: logsComponents.NewLogsDetails(),
+		table:   logsComponents.NewLogsTable(),
+		keyMap:  DefaultKeyMap(),
+	}
+}

internal/tui/styles/background.go 🔗

@@ -1,123 +0,0 @@
-package styles
-
-import (
-	"fmt"
-	"regexp"
-	"strings"
-
-	"github.com/charmbracelet/lipgloss"
-)
-
-var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
-
-func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) {
-	r, g, b, a := c.RGBA()
-
-	// Un-premultiply alpha if needed
-	if a > 0 && a < 0xffff {
-		r = (r * 0xffff) / a
-		g = (g * 0xffff) / a
-		b = (b * 0xffff) / a
-	}
-
-	// Convert from 16-bit to 8-bit color
-	return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
-}
-
-// ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes
-// in `input` with a single 24‑bit background (48;2;R;G;B).
-func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string {
-	// Precompute our new-bg sequence once
-	r, g, b := getColorRGB(newBgColor)
-	newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
-
-	return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
-		const (
-			escPrefixLen = 2 // "\x1b["
-			escSuffixLen = 1 // "m"
-		)
-
-		raw := seq
-		start := escPrefixLen
-		end := len(raw) - escSuffixLen
-
-		var sb strings.Builder
-		// reserve enough space: original content minus bg codes + our newBg
-		sb.Grow((end - start) + len(newBg) + 2)
-
-		// scan from start..end, token by token
-		for i := start; i < end; {
-			// find the next ';' or end
-			j := i
-			for j < end && raw[j] != ';' {
-				j++
-			}
-			token := raw[i:j]
-
-			// fast‑path: skip "48;5;N" or "48;2;R;G;B"
-			if len(token) == 2 && token[0] == '4' && token[1] == '8' {
-				k := j + 1
-				if k < end {
-					// find next token
-					l := k
-					for l < end && raw[l] != ';' {
-						l++
-					}
-					next := raw[k:l]
-					if next == "5" {
-						// skip "48;5;N"
-						m := l + 1
-						for m < end && raw[m] != ';' {
-							m++
-						}
-						i = m + 1
-						continue
-					} else if next == "2" {
-						// skip "48;2;R;G;B"
-						m := l + 1
-						for count := 0; count < 3 && m < end; count++ {
-							for m < end && raw[m] != ';' {
-								m++
-							}
-							m++
-						}
-						i = m
-						continue
-					}
-				}
-			}
-
-			// decide whether to keep this token
-			// manually parse ASCII digits to int
-			isNum := true
-			val := 0
-			for p := i; p < j; p++ {
-				c := raw[p]
-				if c < '0' || c > '9' {
-					isNum = false
-					break
-				}
-				val = val*10 + int(c-'0')
-			}
-			keep := !isNum ||
-				((val < 40 || val > 47) && (val < 100 || val > 107) && val != 49)
-
-			if keep {
-				if sb.Len() > 0 {
-					sb.WriteByte(';')
-				}
-				sb.WriteString(token)
-			}
-			// advance past this token (and the semicolon)
-			i = j + 1
-		}
-
-		// append our new background
-		if sb.Len() > 0 {
-			sb.WriteByte(';')
-		}
-		sb.WriteString(newBg)
-
-		return "\x1b[" + sb.String() + "m"
-	})
-}

internal/tui/styles/chroma.go 🔗

@@ -0,0 +1,79 @@
+package styles
+
+import (
+	"github.com/alecthomas/chroma/v2"
+	"github.com/charmbracelet/glamour/v2/ansi"
+)
+
+func chromaStyle(style ansi.StylePrimitive) string {
+	var s string
+
+	if style.Color != nil {
+		s = *style.Color
+	}
+	if style.BackgroundColor != nil {
+		if s != "" {
+			s += " "
+		}
+		s += "bg:" + *style.BackgroundColor
+	}
+	if style.Italic != nil && *style.Italic {
+		if s != "" {
+			s += " "
+		}
+		s += "italic"
+	}
+	if style.Bold != nil && *style.Bold {
+		if s != "" {
+			s += " "
+		}
+		s += "bold"
+	}
+	if style.Underline != nil && *style.Underline {
+		if s != "" {
+			s += " "
+		}
+		s += "underline"
+	}
+
+	return s
+}
+
+func GetChromaTheme() chroma.StyleEntries {
+	t := CurrentTheme()
+	rules := t.S().Markdown.CodeBlock
+
+	return chroma.StyleEntries{
+		chroma.Text:                chromaStyle(rules.Chroma.Text),
+		chroma.Error:               chromaStyle(rules.Chroma.Error),
+		chroma.Comment:             chromaStyle(rules.Chroma.Comment),
+		chroma.CommentPreproc:      chromaStyle(rules.Chroma.CommentPreproc),
+		chroma.Keyword:             chromaStyle(rules.Chroma.Keyword),
+		chroma.KeywordReserved:     chromaStyle(rules.Chroma.KeywordReserved),
+		chroma.KeywordNamespace:    chromaStyle(rules.Chroma.KeywordNamespace),
+		chroma.KeywordType:         chromaStyle(rules.Chroma.KeywordType),
+		chroma.Operator:            chromaStyle(rules.Chroma.Operator),
+		chroma.Punctuation:         chromaStyle(rules.Chroma.Punctuation),
+		chroma.Name:                chromaStyle(rules.Chroma.Name),
+		chroma.NameBuiltin:         chromaStyle(rules.Chroma.NameBuiltin),
+		chroma.NameTag:             chromaStyle(rules.Chroma.NameTag),
+		chroma.NameAttribute:       chromaStyle(rules.Chroma.NameAttribute),
+		chroma.NameClass:           chromaStyle(rules.Chroma.NameClass),
+		chroma.NameConstant:        chromaStyle(rules.Chroma.NameConstant),
+		chroma.NameDecorator:       chromaStyle(rules.Chroma.NameDecorator),
+		chroma.NameException:       chromaStyle(rules.Chroma.NameException),
+		chroma.NameFunction:        chromaStyle(rules.Chroma.NameFunction),
+		chroma.NameOther:           chromaStyle(rules.Chroma.NameOther),
+		chroma.Literal:             chromaStyle(rules.Chroma.Literal),
+		chroma.LiteralNumber:       chromaStyle(rules.Chroma.LiteralNumber),
+		chroma.LiteralDate:         chromaStyle(rules.Chroma.LiteralDate),
+		chroma.LiteralString:       chromaStyle(rules.Chroma.LiteralString),
+		chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
+		chroma.GenericDeleted:      chromaStyle(rules.Chroma.GenericDeleted),
+		chroma.GenericEmph:         chromaStyle(rules.Chroma.GenericEmph),
+		chroma.GenericInserted:     chromaStyle(rules.Chroma.GenericInserted),
+		chroma.GenericStrong:       chromaStyle(rules.Chroma.GenericStrong),
+		chroma.GenericSubheading:   chromaStyle(rules.Chroma.GenericSubheading),
+		chroma.Background:          chromaStyle(rules.Chroma.Background),
+	}
+}

internal/tui/styles/crush.go 🔗

@@ -0,0 +1,51 @@
+package styles
+
+import (
+	"github.com/charmbracelet/x/exp/charmtone"
+)
+
+func NewCrushTheme() *Theme {
+	return &Theme{
+		Name:   "crush",
+		IsDark: true,
+
+		Primary:   charmtone.Charple,
+		Secondary: charmtone.Dolly,
+		Tertiary:  charmtone.Bok,
+		Accent:    charmtone.Zest,
+		// Backgrounds
+		BgBase:    charmtone.Pepper,
+		BgSubtle:  charmtone.Charcoal,
+		BgOverlay: charmtone.Iron,
+
+		// Foregrounds
+		FgBase:      charmtone.Ash,
+		FgMuted:     charmtone.Squid,
+		FgHalfMuted: charmtone.Smoke,
+		FgSubtle:    charmtone.Oyster,
+		FgSelected:  charmtone.Salt,
+
+		// Borders
+		Border:      charmtone.Charcoal,
+		BorderFocus: charmtone.Charple,
+
+		// Status
+		Success: charmtone.Guac,
+		Error:   charmtone.Sriracha,
+		Warning: charmtone.Uni,
+		Info:    charmtone.Malibu,
+
+		// Colors
+		White: charmtone.Butter,
+
+		Blue: charmtone.Malibu,
+
+		Green:      charmtone.Julep,
+		GreenDark:  charmtone.Guac,
+		GreenLight: charmtone.Bok,
+
+		Red:      charmtone.Coral,
+		RedDark:  charmtone.Sriracha,
+		RedLight: charmtone.Salmon,
+	}
+}

internal/tui/styles/icons.go 🔗

@@ -1,14 +1,17 @@
 package styles
 
 const (
-	OpenCodeIcon string = "⌬"
-
 	CheckIcon    string = "✓"
-	ErrorIcon    string = "✖"
+	ErrorIcon    string = "×"
 	WarningIcon  string = "⚠"
 	InfoIcon     string = ""
 	HintIcon     string = "i"
 	SpinnerIcon  string = "..."
 	LoadingIcon  string = "⟳"
 	DocumentIcon string = "🖼"
+
+	// Tool call icons
+	ToolPending string = "●"
+	ToolSuccess string = "✓"
+	ToolError   string = "×"
 )

internal/tui/styles/markdown.go 🔗

@@ -1,14 +1,9 @@
 package styles
 
 import (
-	"github.com/charmbracelet/glamour"
-	"github.com/charmbracelet/glamour/ansi"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
+	"github.com/charmbracelet/glamour/v2"
 )
 
-const defaultMargin = 1
-
 // Helper functions for style pointers
 func boolPtr(b bool) *bool       { return &b }
 func stringPtr(s string) *string { return &s }
@@ -16,269 +11,10 @@ func uintPtr(u uint) *uint       { return &u }
 
 // returns a glamour TermRenderer configured with the current theme
 func GetMarkdownRenderer(width int) *glamour.TermRenderer {
+	t := CurrentTheme()
 	r, _ := glamour.NewTermRenderer(
-		glamour.WithStyles(generateMarkdownStyleConfig()),
+		glamour.WithStyles(t.S().Markdown),
 		glamour.WithWordWrap(width),
 	)
 	return r
 }
-
-// creates an ansi.StyleConfig for markdown rendering
-// using adaptive colors from the provided theme.
-func generateMarkdownStyleConfig() ansi.StyleConfig {
-	t := theme.CurrentTheme()
-
-	return ansi.StyleConfig{
-		Document: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				BlockPrefix: "",
-				BlockSuffix: "",
-				Color:       stringPtr(adaptiveColorToString(t.MarkdownText())),
-			},
-			Margin: uintPtr(defaultMargin),
-		},
-		BlockQuote: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Color:  stringPtr(adaptiveColorToString(t.MarkdownBlockQuote())),
-				Italic: boolPtr(true),
-				Prefix: "┃ ",
-			},
-			Indent:      uintPtr(1),
-			IndentToken: stringPtr(BaseStyle().Render(" ")),
-		},
-		List: ansi.StyleList{
-			LevelIndent: defaultMargin,
-			StyleBlock: ansi.StyleBlock{
-				IndentToken: stringPtr(BaseStyle().Render(" ")),
-				StylePrimitive: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.MarkdownText())),
-				},
-			},
-		},
-		Heading: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				BlockSuffix: "\n",
-				Color:       stringPtr(adaptiveColorToString(t.MarkdownHeading())),
-				Bold:        boolPtr(true),
-			},
-		},
-		H1: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix: "# ",
-				Color:  stringPtr(adaptiveColorToString(t.MarkdownHeading())),
-				Bold:   boolPtr(true),
-			},
-		},
-		H2: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix: "## ",
-				Color:  stringPtr(adaptiveColorToString(t.MarkdownHeading())),
-				Bold:   boolPtr(true),
-			},
-		},
-		H3: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix: "### ",
-				Color:  stringPtr(adaptiveColorToString(t.MarkdownHeading())),
-				Bold:   boolPtr(true),
-			},
-		},
-		H4: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix: "#### ",
-				Color:  stringPtr(adaptiveColorToString(t.MarkdownHeading())),
-				Bold:   boolPtr(true),
-			},
-		},
-		H5: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix: "##### ",
-				Color:  stringPtr(adaptiveColorToString(t.MarkdownHeading())),
-				Bold:   boolPtr(true),
-			},
-		},
-		H6: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Prefix: "###### ",
-				Color:  stringPtr(adaptiveColorToString(t.MarkdownHeading())),
-				Bold:   boolPtr(true),
-			},
-		},
-		Strikethrough: ansi.StylePrimitive{
-			CrossedOut: boolPtr(true),
-			Color:      stringPtr(adaptiveColorToString(t.TextMuted())),
-		},
-		Emph: ansi.StylePrimitive{
-			Color:  stringPtr(adaptiveColorToString(t.MarkdownEmph())),
-			Italic: boolPtr(true),
-		},
-		Strong: ansi.StylePrimitive{
-			Bold:  boolPtr(true),
-			Color: stringPtr(adaptiveColorToString(t.MarkdownStrong())),
-		},
-		HorizontalRule: ansi.StylePrimitive{
-			Color:  stringPtr(adaptiveColorToString(t.MarkdownHorizontalRule())),
-			Format: "\n─────────────────────────────────────────\n",
-		},
-		Item: ansi.StylePrimitive{
-			BlockPrefix: "• ",
-			Color:       stringPtr(adaptiveColorToString(t.MarkdownListItem())),
-		},
-		Enumeration: ansi.StylePrimitive{
-			BlockPrefix: ". ",
-			Color:       stringPtr(adaptiveColorToString(t.MarkdownListEnumeration())),
-		},
-		Task: ansi.StyleTask{
-			StylePrimitive: ansi.StylePrimitive{},
-			Ticked:         "[✓] ",
-			Unticked:       "[ ] ",
-		},
-		Link: ansi.StylePrimitive{
-			Color:     stringPtr(adaptiveColorToString(t.MarkdownLink())),
-			Underline: boolPtr(true),
-		},
-		LinkText: ansi.StylePrimitive{
-			Color: stringPtr(adaptiveColorToString(t.MarkdownLinkText())),
-			Bold:  boolPtr(true),
-		},
-		Image: ansi.StylePrimitive{
-			Color:     stringPtr(adaptiveColorToString(t.MarkdownImage())),
-			Underline: boolPtr(true),
-			Format:    "🖼 {{.text}}",
-		},
-		ImageText: ansi.StylePrimitive{
-			Color:  stringPtr(adaptiveColorToString(t.MarkdownImageText())),
-			Format: "{{.text}}",
-		},
-		Code: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Color:  stringPtr(adaptiveColorToString(t.MarkdownCode())),
-				Prefix: "",
-				Suffix: "",
-			},
-		},
-		CodeBlock: ansi.StyleCodeBlock{
-			StyleBlock: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					Prefix: " ",
-					Color:  stringPtr(adaptiveColorToString(t.MarkdownCodeBlock())),
-				},
-				Margin: uintPtr(defaultMargin),
-			},
-			Chroma: &ansi.Chroma{
-				Text: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.MarkdownText())),
-				},
-				Error: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.Error())),
-				},
-				Comment: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxComment())),
-				},
-				CommentPreproc: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
-				},
-				Keyword: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
-				},
-				KeywordReserved: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
-				},
-				KeywordNamespace: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
-				},
-				KeywordType: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxType())),
-				},
-				Operator: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxOperator())),
-				},
-				Punctuation: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxPunctuation())),
-				},
-				Name: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxVariable())),
-				},
-				NameBuiltin: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxVariable())),
-				},
-				NameTag: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
-				},
-				NameAttribute: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxFunction())),
-				},
-				NameClass: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxType())),
-				},
-				NameConstant: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxVariable())),
-				},
-				NameDecorator: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxFunction())),
-				},
-				NameFunction: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxFunction())),
-				},
-				LiteralNumber: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxNumber())),
-				},
-				LiteralString: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxString())),
-				},
-				LiteralStringEscape: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
-				},
-				GenericDeleted: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.DiffRemoved())),
-				},
-				GenericEmph: ansi.StylePrimitive{
-					Color:  stringPtr(adaptiveColorToString(t.MarkdownEmph())),
-					Italic: boolPtr(true),
-				},
-				GenericInserted: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.DiffAdded())),
-				},
-				GenericStrong: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.MarkdownStrong())),
-					Bold:  boolPtr(true),
-				},
-				GenericSubheading: ansi.StylePrimitive{
-					Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())),
-				},
-			},
-		},
-		Table: ansi.StyleTable{
-			StyleBlock: ansi.StyleBlock{
-				StylePrimitive: ansi.StylePrimitive{
-					BlockPrefix: "\n",
-					BlockSuffix: "\n",
-				},
-			},
-			CenterSeparator: stringPtr("┼"),
-			ColumnSeparator: stringPtr("│"),
-			RowSeparator:    stringPtr("─"),
-		},
-		DefinitionDescription: ansi.StylePrimitive{
-			BlockPrefix: "\n ❯ ",
-			Color:       stringPtr(adaptiveColorToString(t.MarkdownLinkText())),
-		},
-		Text: ansi.StylePrimitive{
-			Color: stringPtr(adaptiveColorToString(t.MarkdownText())),
-		},
-		Paragraph: ansi.StyleBlock{
-			StylePrimitive: ansi.StylePrimitive{
-				Color: stringPtr(adaptiveColorToString(t.MarkdownText())),
-			},
-		},
-	}
-}
-
-// adaptiveColorToString converts a lipgloss.AdaptiveColor to the appropriate
-// hex color string based on the current terminal background
-func adaptiveColorToString(color lipgloss.AdaptiveColor) string {
-	if lipgloss.HasDarkBackground() {
-		return color.Dark
-	}
-	return color.Light
-}

internal/tui/styles/styles.go 🔗

@@ -1,155 +0,0 @@
-package styles
-
-import (
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-)
-
-var (
-	ImageBakcground = "#212121"
-)
-
-// Style generation functions that use the current theme
-
-// BaseStyle returns the base style with background and foreground colors
-func BaseStyle() lipgloss.Style {
-	t := theme.CurrentTheme()
-	return lipgloss.NewStyle().
-		Background(t.Background()).
-		Foreground(t.Text())
-}
-
-// Regular returns a basic unstyled lipgloss.Style
-func Regular() lipgloss.Style {
-	return lipgloss.NewStyle()
-}
-
-// Bold returns a bold style
-func Bold() lipgloss.Style {
-	return Regular().Bold(true)
-}
-
-// Padded returns a style with horizontal padding
-func Padded() lipgloss.Style {
-	return Regular().Padding(0, 1)
-}
-
-// Border returns a style with a normal border
-func Border() lipgloss.Style {
-	t := theme.CurrentTheme()
-	return Regular().
-		Border(lipgloss.NormalBorder()).
-		BorderForeground(t.BorderNormal())
-}
-
-// ThickBorder returns a style with a thick border
-func ThickBorder() lipgloss.Style {
-	t := theme.CurrentTheme()
-	return Regular().
-		Border(lipgloss.ThickBorder()).
-		BorderForeground(t.BorderNormal())
-}
-
-// DoubleBorder returns a style with a double border
-func DoubleBorder() lipgloss.Style {
-	t := theme.CurrentTheme()
-	return Regular().
-		Border(lipgloss.DoubleBorder()).
-		BorderForeground(t.BorderNormal())
-}
-
-// FocusedBorder returns a style with a border using the focused border color
-func FocusedBorder() lipgloss.Style {
-	t := theme.CurrentTheme()
-	return Regular().
-		Border(lipgloss.NormalBorder()).
-		BorderForeground(t.BorderFocused())
-}
-
-// DimBorder returns a style with a border using the dim border color
-func DimBorder() lipgloss.Style {
-	t := theme.CurrentTheme()
-	return Regular().
-		Border(lipgloss.NormalBorder()).
-		BorderForeground(t.BorderDim())
-}
-
-// PrimaryColor returns the primary color from the current theme
-func PrimaryColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().Primary()
-}
-
-// SecondaryColor returns the secondary color from the current theme
-func SecondaryColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().Secondary()
-}
-
-// AccentColor returns the accent color from the current theme
-func AccentColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().Accent()
-}
-
-// ErrorColor returns the error color from the current theme
-func ErrorColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().Error()
-}
-
-// WarningColor returns the warning color from the current theme
-func WarningColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().Warning()
-}
-
-// SuccessColor returns the success color from the current theme
-func SuccessColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().Success()
-}
-
-// InfoColor returns the info color from the current theme
-func InfoColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().Info()
-}
-
-// TextColor returns the text color from the current theme
-func TextColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().Text()
-}
-
-// TextMutedColor returns the muted text color from the current theme
-func TextMutedColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().TextMuted()
-}
-
-// TextEmphasizedColor returns the emphasized text color from the current theme
-func TextEmphasizedColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().TextEmphasized()
-}
-
-// BackgroundColor returns the background color from the current theme
-func BackgroundColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().Background()
-}
-
-// BackgroundSecondaryColor returns the secondary background color from the current theme
-func BackgroundSecondaryColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BackgroundSecondary()
-}
-
-// BackgroundDarkerColor returns the darker background color from the current theme
-func BackgroundDarkerColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BackgroundDarker()
-}
-
-// BorderNormalColor returns the normal border color from the current theme
-func BorderNormalColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BorderNormal()
-}
-
-// BorderFocusedColor returns the focused border color from the current theme
-func BorderFocusedColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BorderFocused()
-}
-
-// BorderDimColor returns the dim border color from the current theme
-func BorderDimColor() lipgloss.AdaptiveColor {
-	return theme.CurrentTheme().BorderDim()
-}

internal/tui/styles/theme.go 🔗

@@ -0,0 +1,630 @@
+package styles
+
+import (
+	"fmt"
+	"image/color"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/filepicker"
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/textarea"
+	"github.com/charmbracelet/bubbles/v2/textinput"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/glamour/v2/ansi"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/lucasb-eyer/go-colorful"
+	"github.com/rivo/uniseg"
+)
+
+const (
+	defaultListIndent      = 2
+	defaultListLevelIndent = 4
+	defaultMargin          = 2
+)
+
+type Theme struct {
+	Name   string
+	IsDark bool
+
+	Primary   color.Color
+	Secondary color.Color
+	Tertiary  color.Color
+	Accent    color.Color
+
+	BgBase    color.Color
+	BgSubtle  color.Color
+	BgOverlay color.Color
+
+	FgBase      color.Color
+	FgMuted     color.Color
+	FgHalfMuted color.Color
+	FgSubtle    color.Color
+	FgSelected  color.Color
+
+	Border      color.Color
+	BorderFocus color.Color
+
+	Success color.Color
+	Error   color.Color
+	Warning color.Color
+	Info    color.Color
+
+	// Colors
+	// White
+	White color.Color
+	// Blues
+	Blue color.Color
+
+	// Greens
+	Green      color.Color
+	GreenDark  color.Color
+	GreenLight color.Color
+
+	// Reds
+	Red      color.Color
+	RedDark  color.Color
+	RedLight color.Color
+
+	// TODO: add any others needed
+
+	styles *Styles
+}
+
+type Diff struct {
+	Added               color.Color
+	Removed             color.Color
+	Context             color.Color
+	HunkHeader          color.Color
+	HighlightAdded      color.Color
+	HighlightRemoved    color.Color
+	AddedBg             color.Color
+	RemovedBg           color.Color
+	ContextBg           color.Color
+	LineNumber          color.Color
+	AddedLineNumberBg   color.Color
+	RemovedLineNumberBg color.Color
+}
+
+type Styles struct {
+	Base         lipgloss.Style
+	SelectedBase lipgloss.Style
+
+	Title        lipgloss.Style
+	Subtitle     lipgloss.Style
+	Text         lipgloss.Style
+	TextSelected lipgloss.Style
+	Muted        lipgloss.Style
+	Subtle       lipgloss.Style
+
+	Success lipgloss.Style
+	Error   lipgloss.Style
+	Warning lipgloss.Style
+	Info    lipgloss.Style
+
+	// Markdown & Chroma
+	Markdown ansi.StyleConfig
+
+	// Inputs
+	TextInput textinput.Styles
+	TextArea  textarea.Styles
+
+	// Help
+	Help help.Styles
+
+	// Diff
+	Diff Diff
+
+	// FilePicker
+	FilePicker filepicker.Styles
+}
+
+func (t *Theme) S() *Styles {
+	if t.styles == nil {
+		t.styles = t.buildStyles()
+	}
+	return t.styles
+}
+
+func (t *Theme) buildStyles() *Styles {
+	base := lipgloss.NewStyle().
+		Foreground(t.FgBase)
+	return &Styles{
+		Base: base,
+
+		SelectedBase: base.Background(t.Primary),
+
+		Title: base.
+			Foreground(t.Accent).
+			Bold(true),
+
+		Subtitle: base.
+			Foreground(t.Secondary).
+			Bold(true),
+
+		Text:         base,
+		TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
+
+		Muted: base.Foreground(t.FgMuted),
+
+		Subtle: base.Foreground(t.FgSubtle),
+
+		Success: base.Foreground(t.Success),
+
+		Error: base.Foreground(t.Error),
+
+		Warning: base.Foreground(t.Warning),
+
+		Info: base.Foreground(t.Info),
+
+		TextInput: textinput.Styles{
+			Focused: textinput.StyleState{
+				Text:        base,
+				Placeholder: base.Foreground(t.FgMuted),
+				Prompt:      base.Foreground(t.Tertiary),
+				Suggestion:  base.Foreground(t.FgMuted),
+			},
+			Blurred: textinput.StyleState{
+				Text:        base.Foreground(t.FgMuted),
+				Placeholder: base.Foreground(t.FgMuted),
+				Prompt:      base.Foreground(t.FgMuted),
+				Suggestion:  base.Foreground(t.FgMuted),
+			},
+			Cursor: textinput.CursorStyle{
+				Color: t.Secondary,
+				Shape: tea.CursorBar,
+				Blink: true,
+			},
+		},
+		TextArea: textarea.Styles{
+			Focused: textarea.StyleState{
+				Base:             base,
+				Text:             base,
+				LineNumber:       base.Foreground(t.FgSubtle),
+				CursorLine:       base,
+				CursorLineNumber: base.Foreground(t.FgSubtle),
+				Placeholder:      base.Foreground(t.FgMuted),
+				Prompt:           base.Foreground(t.Tertiary),
+			},
+			Blurred: textarea.StyleState{
+				Base:             base,
+				Text:             base.Foreground(t.FgMuted),
+				LineNumber:       base.Foreground(t.FgMuted),
+				CursorLine:       base,
+				CursorLineNumber: base.Foreground(t.FgMuted),
+				Placeholder:      base.Foreground(t.FgMuted),
+				Prompt:           base.Foreground(t.FgMuted),
+			},
+			Cursor: textarea.CursorStyle{
+				Color: t.Secondary,
+				Shape: tea.CursorBar,
+				Blink: true,
+			},
+		},
+
+		// TODO:  update using the colors and add colors if missing
+		Markdown: ansi.StyleConfig{
+			Document: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					// BlockPrefix: "\n",
+					// BlockSuffix: "\n",
+					Color: stringPtr("252"),
+				},
+				// Margin: uintPtr(defaultMargin),
+			},
+			BlockQuote: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{},
+				Indent:         uintPtr(1),
+				IndentToken:    stringPtr("│ "),
+			},
+			List: ansi.StyleList{
+				LevelIndent: defaultListIndent,
+			},
+			Heading: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					BlockSuffix: "\n",
+					Color:       stringPtr("39"),
+					Bold:        boolPtr(true),
+				},
+			},
+			H1: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Prefix:          " ",
+					Suffix:          " ",
+					Color:           stringPtr("228"),
+					BackgroundColor: stringPtr("63"),
+					Bold:            boolPtr(true),
+				},
+			},
+			H2: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Prefix: "## ",
+				},
+			},
+			H3: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Prefix: "### ",
+				},
+			},
+			H4: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Prefix: "#### ",
+				},
+			},
+			H5: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Prefix: "##### ",
+				},
+			},
+			H6: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Prefix: "###### ",
+					Color:  stringPtr("35"),
+					Bold:   boolPtr(false),
+				},
+			},
+			Strikethrough: ansi.StylePrimitive{
+				CrossedOut: boolPtr(true),
+			},
+			Emph: ansi.StylePrimitive{
+				Italic: boolPtr(true),
+			},
+			Strong: ansi.StylePrimitive{
+				Bold: boolPtr(true),
+			},
+			HorizontalRule: ansi.StylePrimitive{
+				Color:  stringPtr("240"),
+				Format: "\n--------\n",
+			},
+			Item: ansi.StylePrimitive{
+				BlockPrefix: "• ",
+			},
+			Enumeration: ansi.StylePrimitive{
+				BlockPrefix: ". ",
+			},
+			Task: ansi.StyleTask{
+				StylePrimitive: ansi.StylePrimitive{},
+				Ticked:         "[✓] ",
+				Unticked:       "[ ] ",
+			},
+			Link: ansi.StylePrimitive{
+				Color:     stringPtr("30"),
+				Underline: boolPtr(true),
+			},
+			LinkText: ansi.StylePrimitive{
+				Color: stringPtr("35"),
+				Bold:  boolPtr(true),
+			},
+			Image: ansi.StylePrimitive{
+				Color:     stringPtr("212"),
+				Underline: boolPtr(true),
+			},
+			ImageText: ansi.StylePrimitive{
+				Color:  stringPtr("243"),
+				Format: "Image: {{.text}} →",
+			},
+			Code: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Prefix:          " ",
+					Suffix:          " ",
+					Color:           stringPtr("203"),
+					BackgroundColor: stringPtr("236"),
+				},
+			},
+			CodeBlock: ansi.StyleCodeBlock{
+				StyleBlock: ansi.StyleBlock{
+					StylePrimitive: ansi.StylePrimitive{
+						Color: stringPtr("244"),
+					},
+					Margin: uintPtr(defaultMargin),
+				},
+				Chroma: &ansi.Chroma{
+					Text: ansi.StylePrimitive{
+						Color: stringPtr("#C4C4C4"),
+					},
+					Error: ansi.StylePrimitive{
+						Color:           stringPtr("#F1F1F1"),
+						BackgroundColor: stringPtr("#F05B5B"),
+					},
+					Comment: ansi.StylePrimitive{
+						Color: stringPtr("#676767"),
+					},
+					CommentPreproc: ansi.StylePrimitive{
+						Color: stringPtr("#FF875F"),
+					},
+					Keyword: ansi.StylePrimitive{
+						Color: stringPtr("#00AAFF"),
+					},
+					KeywordReserved: ansi.StylePrimitive{
+						Color: stringPtr("#FF5FD2"),
+					},
+					KeywordNamespace: ansi.StylePrimitive{
+						Color: stringPtr("#FF5F87"),
+					},
+					KeywordType: ansi.StylePrimitive{
+						Color: stringPtr("#6E6ED8"),
+					},
+					Operator: ansi.StylePrimitive{
+						Color: stringPtr("#EF8080"),
+					},
+					Punctuation: ansi.StylePrimitive{
+						Color: stringPtr("#E8E8A8"),
+					},
+					Name: ansi.StylePrimitive{
+						Color: stringPtr("#C4C4C4"),
+					},
+					NameBuiltin: ansi.StylePrimitive{
+						Color: stringPtr("#FF8EC7"),
+					},
+					NameTag: ansi.StylePrimitive{
+						Color: stringPtr("#B083EA"),
+					},
+					NameAttribute: ansi.StylePrimitive{
+						Color: stringPtr("#7A7AE6"),
+					},
+					NameClass: ansi.StylePrimitive{
+						Color:     stringPtr("#F1F1F1"),
+						Underline: boolPtr(true),
+						Bold:      boolPtr(true),
+					},
+					NameDecorator: ansi.StylePrimitive{
+						Color: stringPtr("#FFFF87"),
+					},
+					NameFunction: ansi.StylePrimitive{
+						Color: stringPtr("#00D787"),
+					},
+					LiteralNumber: ansi.StylePrimitive{
+						Color: stringPtr("#6EEFC0"),
+					},
+					LiteralString: ansi.StylePrimitive{
+						Color: stringPtr("#C69669"),
+					},
+					LiteralStringEscape: ansi.StylePrimitive{
+						Color: stringPtr("#AFFFD7"),
+					},
+					GenericDeleted: ansi.StylePrimitive{
+						Color: stringPtr("#FD5B5B"),
+					},
+					GenericEmph: ansi.StylePrimitive{
+						Italic: boolPtr(true),
+					},
+					GenericInserted: ansi.StylePrimitive{
+						Color: stringPtr("#00D787"),
+					},
+					GenericStrong: ansi.StylePrimitive{
+						Bold: boolPtr(true),
+					},
+					GenericSubheading: ansi.StylePrimitive{
+						Color: stringPtr("#777777"),
+					},
+					Background: ansi.StylePrimitive{
+						BackgroundColor: stringPtr("#373737"),
+					},
+				},
+			},
+			Table: ansi.StyleTable{
+				StyleBlock: ansi.StyleBlock{
+					StylePrimitive: ansi.StylePrimitive{},
+				},
+			},
+			DefinitionDescription: ansi.StylePrimitive{
+				BlockPrefix: "\n ",
+			},
+		},
+
+		Help: help.Styles{
+			ShortKey:       base.Foreground(t.FgMuted),
+			ShortDesc:      base.Foreground(t.FgSubtle),
+			ShortSeparator: base.Foreground(t.Border),
+			Ellipsis:       base.Foreground(t.Border),
+			FullKey:        base.Foreground(t.FgMuted),
+			FullDesc:       base.Foreground(t.FgSubtle),
+			FullSeparator:  base.Foreground(t.Border),
+		},
+
+		// TODO: Fix this this is bad
+		Diff: Diff{
+			Added:               t.Green,
+			Removed:             t.Red,
+			Context:             t.FgSubtle,
+			HunkHeader:          t.FgSubtle,
+			HighlightAdded:      t.GreenLight,
+			HighlightRemoved:    t.RedLight,
+			AddedBg:             t.GreenDark,
+			RemovedBg:           t.RedDark,
+			ContextBg:           t.BgSubtle,
+			LineNumber:          t.FgMuted,
+			AddedLineNumberBg:   t.GreenDark,
+			RemovedLineNumberBg: t.RedDark,
+		},
+
+		FilePicker: filepicker.Styles{
+			DisabledCursor:   base.Foreground(t.FgMuted),
+			Cursor:           base.Foreground(t.FgBase),
+			Symlink:          base.Foreground(t.FgSubtle),
+			Directory:        base.Foreground(t.Primary),
+			File:             base.Foreground(t.FgBase),
+			DisabledFile:     base.Foreground(t.FgMuted),
+			DisabledSelected: base.Background(t.BgOverlay).Foreground(t.FgMuted),
+			Permission:       base.Foreground(t.FgMuted),
+			Selected:         base.Background(t.Primary).Foreground(t.FgBase),
+			FileSize:         base.Foreground(t.FgMuted),
+			EmptyDirectory:   base.Foreground(t.FgMuted).PaddingLeft(2).SetString("Empty directory"),
+		},
+	}
+}
+
+type Manager struct {
+	themes  map[string]*Theme
+	current *Theme
+}
+
+var defaultManager *Manager
+
+func SetDefaultManager(m *Manager) {
+	defaultManager = m
+}
+
+func DefaultManager() *Manager {
+	if defaultManager == nil {
+		defaultManager = NewManager("crush")
+	}
+	return defaultManager
+}
+
+func CurrentTheme() *Theme {
+	if defaultManager == nil {
+		defaultManager = NewManager("crush")
+	}
+	return defaultManager.Current()
+}
+
+func NewManager(defaultTheme string) *Manager {
+	m := &Manager{
+		themes: make(map[string]*Theme),
+	}
+
+	m.Register(NewCrushTheme())
+
+	m.current = m.themes[defaultTheme]
+
+	return m
+}
+
+func (m *Manager) Register(theme *Theme) {
+	m.themes[theme.Name] = theme
+}
+
+func (m *Manager) Current() *Theme {
+	return m.current
+}
+
+func (m *Manager) SetTheme(name string) error {
+	if theme, ok := m.themes[name]; ok {
+		m.current = theme
+		return nil
+	}
+	return fmt.Errorf("theme %s not found", name)
+}
+
+func (m *Manager) List() []string {
+	names := make([]string, 0, len(m.themes))
+	for name := range m.themes {
+		names = append(names, name)
+	}
+	return names
+}
+
+// ParseHex converts hex string to color
+func ParseHex(hex string) color.Color {
+	var r, g, b uint8
+	fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
+	return color.RGBA{R: r, G: g, B: b, A: 255}
+}
+
+// Alpha returns a color with transparency
+func Alpha(c color.Color, alpha uint8) color.Color {
+	r, g, b, _ := c.RGBA()
+	return color.RGBA{
+		R: uint8(r >> 8),
+		G: uint8(g >> 8),
+		B: uint8(b >> 8),
+		A: alpha,
+	}
+}
+
+// Darken makes a color darker by percentage (0-100)
+func Darken(c color.Color, percent float64) color.Color {
+	r, g, b, a := c.RGBA()
+	factor := 1.0 - percent/100.0
+	return color.RGBA{
+		R: uint8(float64(r>>8) * factor),
+		G: uint8(float64(g>>8) * factor),
+		B: uint8(float64(b>>8) * factor),
+		A: uint8(a >> 8),
+	}
+}
+
+// Lighten makes a color lighter by percentage (0-100)
+func Lighten(c color.Color, percent float64) color.Color {
+	r, g, b, a := c.RGBA()
+	factor := percent / 100.0
+	return color.RGBA{
+		R: uint8(min(255, float64(r>>8)+255*factor)),
+		G: uint8(min(255, float64(g>>8)+255*factor)),
+		B: uint8(min(255, float64(b>>8)+255*factor)),
+		A: uint8(a >> 8),
+	}
+}
+
+// ApplyForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
+	if input == "" {
+		return ""
+	}
+
+	var o strings.Builder
+	if len(input) == 1 {
+		return lipgloss.NewStyle().Foreground(color1).Render(input)
+	}
+
+	var clusters []string
+	gr := uniseg.NewGraphemes(input)
+	for gr.Next() {
+		clusters = append(clusters, string(gr.Runes()))
+	}
+
+	ramp := blendColors(len(clusters), color1, color2)
+	for i, c := range ramp {
+		fmt.Fprint(&o, CurrentTheme().S().Base.Foreground(c).Render(clusters[i]))
+	}
+
+	return o.String()
+}
+
+// blendColors returns a slice of colors blended between the given keys.
+// Blending is done in Hcl to stay in gamut.
+func blendColors(size int, stops ...color.Color) []color.Color {
+	if len(stops) < 2 {
+		return nil
+	}
+
+	stopsPrime := make([]colorful.Color, len(stops))
+	for i, k := range stops {
+		stopsPrime[i], _ = colorful.MakeColor(k)
+	}
+
+	numSegments := len(stopsPrime) - 1
+	blended := make([]color.Color, 0, size)
+
+	// Calculate how many colors each segment should have.
+	segmentSizes := make([]int, numSegments)
+	baseSize := size / numSegments
+	remainder := size % numSegments
+
+	// Distribute the remainder across segments.
+	for i := range numSegments {
+		segmentSizes[i] = baseSize
+		if i < remainder {
+			segmentSizes[i]++
+		}
+	}
+
+	// Generate colors for each segment.
+	for i := range numSegments {
+		c1 := stopsPrime[i]
+		c2 := stopsPrime[i+1]
+		segmentSize := segmentSizes[i]
+
+		for j := range segmentSize {
+			var t float64
+			if segmentSize > 1 {
+				t = float64(j) / float64(segmentSize-1)
+			}
+			c := c1.BlendHcl(c2, t)
+			blended = append(blended, c)
+		}
+	}
+
+	return blended
+}

internal/tui/theme/catppuccin.go 🔗

@@ -1,248 +0,0 @@
-package theme
-
-import (
-	catppuccin "github.com/catppuccin/go"
-	"github.com/charmbracelet/lipgloss"
-)
-
-// CatppuccinTheme implements the Theme interface with Catppuccin colors.
-// It provides both dark (Mocha) and light (Latte) variants.
-type CatppuccinTheme struct {
-	BaseTheme
-}
-
-// NewCatppuccinTheme creates a new instance of the Catppuccin theme.
-func NewCatppuccinTheme() *CatppuccinTheme {
-	// Get the Catppuccin palettes
-	mocha := catppuccin.Mocha
-	latte := catppuccin.Latte
-
-	theme := &CatppuccinTheme{}
-
-	// Base colors
-	theme.PrimaryColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Blue().Hex,
-		Light: latte.Blue().Hex,
-	}
-	theme.SecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Mauve().Hex,
-		Light: latte.Mauve().Hex,
-	}
-	theme.AccentColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Peach().Hex,
-		Light: latte.Peach().Hex,
-	}
-
-	// Status colors
-	theme.ErrorColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Red().Hex,
-		Light: latte.Red().Hex,
-	}
-	theme.WarningColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Peach().Hex,
-		Light: latte.Peach().Hex,
-	}
-	theme.SuccessColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Green().Hex,
-		Light: latte.Green().Hex,
-	}
-	theme.InfoColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Blue().Hex,
-		Light: latte.Blue().Hex,
-	}
-
-	// Text colors
-	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Text().Hex,
-		Light: latte.Text().Hex,
-	}
-	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Subtext0().Hex,
-		Light: latte.Subtext0().Hex,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Lavender().Hex,
-		Light: latte.Lavender().Hex,
-	}
-
-	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  "#212121", // From existing styles
-		Light: "#EEEEEE", // Light equivalent
-	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  "#2c2c2c", // From existing styles
-		Light: "#E0E0E0", // Light equivalent
-	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  "#181818", // From existing styles
-		Light: "#F5F5F5", // Light equivalent
-	}
-
-	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  "#4b4c5c", // From existing styles
-		Light: "#BDBDBD", // Light equivalent
-	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Blue().Hex,
-		Light: latte.Blue().Hex,
-	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Surface0().Hex,
-		Light: latte.Surface0().Hex,
-	}
-
-	// Diff view colors
-	theme.DiffAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#478247", // From existing diff.go
-		Light: "#2E7D32", // Light equivalent
-	}
-	theme.DiffRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#7C4444", // From existing diff.go
-		Light: "#C62828", // Light equivalent
-	}
-	theme.DiffContextColor = lipgloss.AdaptiveColor{
-		Dark:  "#a0a0a0", // From existing diff.go
-		Light: "#757575", // Light equivalent
-	}
-	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  "#a0a0a0", // From existing diff.go
-		Light: "#757575", // Light equivalent
-	}
-	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#DAFADA", // From existing diff.go
-		Light: "#A5D6A7", // Light equivalent
-	}
-	theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#FADADD", // From existing diff.go
-		Light: "#EF9A9A", // Light equivalent
-	}
-	theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#303A30", // From existing diff.go
-		Light: "#E8F5E9", // Light equivalent
-	}
-	theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#3A3030", // From existing diff.go
-		Light: "#FFEBEE", // Light equivalent
-	}
-	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#212121", // From existing diff.go
-		Light: "#F5F5F5", // Light equivalent
-	}
-	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  "#888888", // From existing diff.go
-		Light: "#9E9E9E", // Light equivalent
-	}
-	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#293229", // From existing diff.go
-		Light: "#C8E6C9", // Light equivalent
-	}
-	theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#332929", // From existing diff.go
-		Light: "#FFCDD2", // Light equivalent
-	}
-
-	// Markdown colors
-	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Text().Hex,
-		Light: latte.Text().Hex,
-	}
-	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Mauve().Hex,
-		Light: latte.Mauve().Hex,
-	}
-	theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Sky().Hex,
-		Light: latte.Sky().Hex,
-	}
-	theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Pink().Hex,
-		Light: latte.Pink().Hex,
-	}
-	theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Green().Hex,
-		Light: latte.Green().Hex,
-	}
-	theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Yellow().Hex,
-		Light: latte.Yellow().Hex,
-	}
-	theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Yellow().Hex,
-		Light: latte.Yellow().Hex,
-	}
-	theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Peach().Hex,
-		Light: latte.Peach().Hex,
-	}
-	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Overlay0().Hex,
-		Light: latte.Overlay0().Hex,
-	}
-	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Blue().Hex,
-		Light: latte.Blue().Hex,
-	}
-	theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Sky().Hex,
-		Light: latte.Sky().Hex,
-	}
-	theme.MarkdownImageColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Sapphire().Hex,
-		Light: latte.Sapphire().Hex,
-	}
-	theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Pink().Hex,
-		Light: latte.Pink().Hex,
-	}
-	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Text().Hex,
-		Light: latte.Text().Hex,
-	}
-
-	// Syntax highlighting colors
-	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Overlay1().Hex,
-		Light: latte.Overlay1().Hex,
-	}
-	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Pink().Hex,
-		Light: latte.Pink().Hex,
-	}
-	theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Green().Hex,
-		Light: latte.Green().Hex,
-	}
-	theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Sky().Hex,
-		Light: latte.Sky().Hex,
-	}
-	theme.SyntaxStringColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Yellow().Hex,
-		Light: latte.Yellow().Hex,
-	}
-	theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Teal().Hex,
-		Light: latte.Teal().Hex,
-	}
-	theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Sky().Hex,
-		Light: latte.Sky().Hex,
-	}
-	theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Pink().Hex,
-		Light: latte.Pink().Hex,
-	}
-	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  mocha.Text().Hex,
-		Light: latte.Text().Hex,
-	}
-
-	return theme
-}
-
-func init() {
-	// Register the Catppuccin theme with the theme manager
-	RegisterTheme("catppuccin", NewCatppuccinTheme())
-}

internal/tui/theme/dracula.go 🔗

@@ -1,274 +0,0 @@
-package theme
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-// DraculaTheme implements the Theme interface with Dracula colors.
-// It provides both dark and light variants, though Dracula is primarily a dark theme.
-type DraculaTheme struct {
-	BaseTheme
-}
-
-// NewDraculaTheme creates a new instance of the Dracula theme.
-func NewDraculaTheme() *DraculaTheme {
-	// Dracula color palette
-	// Official colors from https://draculatheme.com/
-	darkBackground := "#282a36"
-	darkCurrentLine := "#44475a"
-	darkSelection := "#44475a"
-	darkForeground := "#f8f8f2"
-	darkComment := "#6272a4"
-	darkCyan := "#8be9fd"
-	darkGreen := "#50fa7b"
-	darkOrange := "#ffb86c"
-	darkPink := "#ff79c6"
-	darkPurple := "#bd93f9"
-	darkRed := "#ff5555"
-	darkYellow := "#f1fa8c"
-	darkBorder := "#44475a"
-
-	// Light mode approximation (Dracula is primarily a dark theme)
-	lightBackground := "#f8f8f2"
-	lightCurrentLine := "#e6e6e6"
-	lightSelection := "#d8d8d8"
-	lightForeground := "#282a36"
-	lightComment := "#6272a4"
-	lightCyan := "#0097a7"
-	lightGreen := "#388e3c"
-	lightOrange := "#f57c00"
-	lightPink := "#d81b60"
-	lightPurple := "#7e57c2"
-	lightRed := "#e53935"
-	lightYellow := "#fbc02d"
-	lightBorder := "#d8d8d8"
-
-	theme := &DraculaTheme{}
-
-	// Base colors
-	theme.PrimaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.SecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkPink,
-		Light: lightPink,
-	}
-	theme.AccentColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-
-	// Status colors
-	theme.ErrorColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.WarningColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SuccessColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.InfoColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-
-	// Text colors
-	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-
-	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCurrentLine,
-		Light: lightCurrentLine,
-	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  "#21222c", // Slightly darker than background
-		Light: "#ffffff", // Slightly lighter than background
-	}
-
-	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  darkBorder,
-		Light: lightBorder,
-	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  darkSelection,
-		Light: lightSelection,
-	}
-
-	// Diff view colors
-	theme.DiffAddedColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.DiffRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.DiffContextColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#50fa7b",
-		Light: "#a5d6a7",
-	}
-	theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#ff5555",
-		Light: "#ef9a9a",
-	}
-	theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#2c3b2c",
-		Light: "#e8f5e9",
-	}
-	theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#3b2c2c",
-		Light: "#ffebee",
-	}
-	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#253025",
-		Light: "#c8e6c9",
-	}
-	theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#302525",
-		Light: "#ffcdd2",
-	}
-
-	// Markdown colors
-	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
-		Dark:  darkPink,
-		Light: lightPink,
-	}
-	theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownImageColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	// Syntax highlighting colors
-	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
-		Dark:  darkPink,
-		Light: lightPink,
-	}
-	theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SyntaxStringColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
-		Dark:  darkPink,
-		Light: lightPink,
-	}
-	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	return theme
-}
-
-func init() {
-	// Register the Dracula theme with the theme manager
-	RegisterTheme("dracula", NewDraculaTheme())
-}

internal/tui/theme/flexoki.go 🔗

@@ -1,282 +0,0 @@
-package theme
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-// Flexoki color palette constants
-const (
-	// Base colors
-	flexokiPaper    = "#FFFCF0" // Paper (lightest)
-	flexokiBase50   = "#F2F0E5" // bg-2 (light)
-	flexokiBase100  = "#E6E4D9" // ui (light)
-	flexokiBase150  = "#DAD8CE" // ui-2 (light)
-	flexokiBase200  = "#CECDC3" // ui-3 (light)
-	flexokiBase300  = "#B7B5AC" // tx-3 (light)
-	flexokiBase500  = "#878580" // tx-2 (light)
-	flexokiBase600  = "#6F6E69" // tx (light)
-	flexokiBase700  = "#575653" // tx-3 (dark)
-	flexokiBase800  = "#403E3C" // ui-3 (dark)
-	flexokiBase850  = "#343331" // ui-2 (dark)
-	flexokiBase900  = "#282726" // ui (dark)
-	flexokiBase950  = "#1C1B1A" // bg-2 (dark)
-	flexokiBlack    = "#100F0F" // bg (darkest)
-
-	// Accent colors - Light theme (600)
-	flexokiRed600     = "#AF3029"
-	flexokiOrange600  = "#BC5215"
-	flexokiYellow600  = "#AD8301"
-	flexokiGreen600   = "#66800B"
-	flexokiCyan600    = "#24837B"
-	flexokiBlue600    = "#205EA6"
-	flexokiPurple600  = "#5E409D"
-	flexokiMagenta600 = "#A02F6F"
-
-	// Accent colors - Dark theme (400)
-	flexokiRed400     = "#D14D41"
-	flexokiOrange400  = "#DA702C"
-	flexokiYellow400  = "#D0A215"
-	flexokiGreen400   = "#879A39"
-	flexokiCyan400    = "#3AA99F"
-	flexokiBlue400    = "#4385BE"
-	flexokiPurple400  = "#8B7EC8"
-	flexokiMagenta400 = "#CE5D97"
-)
-
-// FlexokiTheme implements the Theme interface with Flexoki colors.
-// It provides both dark and light variants.
-type FlexokiTheme struct {
-	BaseTheme
-}
-
-// NewFlexokiTheme creates a new instance of the Flexoki theme.
-func NewFlexokiTheme() *FlexokiTheme {
-	theme := &FlexokiTheme{}
-
-	// Base colors
-	theme.PrimaryColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBlue400,
-		Light: flexokiBlue600,
-	}
-	theme.SecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiPurple400,
-		Light: flexokiPurple600,
-	}
-	theme.AccentColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiOrange400,
-		Light: flexokiOrange600,
-	}
-
-	// Status colors
-	theme.ErrorColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiRed400,
-		Light: flexokiRed600,
-	}
-	theme.WarningColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiYellow400,
-		Light: flexokiYellow600,
-	}
-	theme.SuccessColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiGreen400,
-		Light: flexokiGreen600,
-	}
-	theme.InfoColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiCyan400,
-		Light: flexokiCyan600,
-	}
-
-	// Text colors
-	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase300,
-		Light: flexokiBase600,
-	}
-	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase700,
-		Light: flexokiBase500,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiYellow400,
-		Light: flexokiYellow600,
-	}
-
-	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBlack,
-		Light: flexokiPaper,
-	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase950,
-		Light: flexokiBase50,
-	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase900,
-		Light: flexokiBase100,
-	}
-
-	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase900,
-		Light: flexokiBase100,
-	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBlue400,
-		Light: flexokiBlue600,
-	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase850,
-		Light: flexokiBase150,
-	}
-
-	// Diff view colors
-	theme.DiffAddedColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiGreen400,
-		Light: flexokiGreen600,
-	}
-	theme.DiffRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiRed400,
-		Light: flexokiRed600,
-	}
-	theme.DiffContextColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase700,
-		Light: flexokiBase500,
-	}
-	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase700,
-		Light: flexokiBase500,
-	}
-	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiGreen400,
-		Light: flexokiGreen600,
-	}
-	theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiRed400,
-		Light: flexokiRed600,
-	}
-	theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#1D2419", // Darker green background
-		Light: "#EFF2E2", // Light green background
-	}
-	theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#241919", // Darker red background
-		Light: "#F2E2E2", // Light red background
-	}
-	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBlack,
-		Light: flexokiPaper,
-	}
-	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase700,
-		Light: flexokiBase500,
-	}
-	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#1A2017", // Slightly darker green
-		Light: "#E5EBD9", // Light green
-	}
-	theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#201717", // Slightly darker red
-		Light: "#EBD9D9", // Light red
-	}
-
-	// Markdown colors
-	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase300,
-		Light: flexokiBase600,
-	}
-	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiYellow400,
-		Light: flexokiYellow600,
-	}
-	theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiCyan400,
-		Light: flexokiCyan600,
-	}
-	theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiMagenta400,
-		Light: flexokiMagenta600,
-	}
-	theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiGreen400,
-		Light: flexokiGreen600,
-	}
-	theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiCyan400,
-		Light: flexokiCyan600,
-	}
-	theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiYellow400,
-		Light: flexokiYellow600,
-	}
-	theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiOrange400,
-		Light: flexokiOrange600,
-	}
-	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase800,
-		Light: flexokiBase200,
-	}
-	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBlue400,
-		Light: flexokiBlue600,
-	}
-	theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBlue400,
-		Light: flexokiBlue600,
-	}
-	theme.MarkdownImageColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiPurple400,
-		Light: flexokiPurple600,
-	}
-	theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiMagenta400,
-		Light: flexokiMagenta600,
-	}
-	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase300,
-		Light: flexokiBase600,
-	}
-
-	// Syntax highlighting colors (based on Flexoki's mappings)
-	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase700, // tx-3
-		Light: flexokiBase300, // tx-3
-	}
-	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiGreen400, // gr
-		Light: flexokiGreen600, // gr
-	}
-	theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiOrange400, // or
-		Light: flexokiOrange600, // or
-	}
-	theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBlue400, // bl
-		Light: flexokiBlue600, // bl
-	}
-	theme.SyntaxStringColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiCyan400, // cy
-		Light: flexokiCyan600, // cy
-	}
-	theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiPurple400, // pu
-		Light: flexokiPurple600, // pu
-	}
-	theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiYellow400, // ye
-		Light: flexokiYellow600, // ye
-	}
-	theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase500, // tx-2
-		Light: flexokiBase500, // tx-2
-	}
-	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  flexokiBase500, // tx-2
-		Light: flexokiBase500, // tx-2
-	}
-
-	return theme
-}
-
-func init() {
-	// Register the Flexoki theme with the theme manager
-	RegisterTheme("flexoki", NewFlexokiTheme())
-}

internal/tui/theme/gruvbox.go 🔗

@@ -1,302 +0,0 @@
-package theme
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-// Gruvbox color palette constants
-const (
-	// Dark theme colors
-	gruvboxDarkBg0          = "#282828"
-	gruvboxDarkBg0Soft      = "#32302f"
-	gruvboxDarkBg1          = "#3c3836"
-	gruvboxDarkBg2          = "#504945"
-	gruvboxDarkBg3          = "#665c54"
-	gruvboxDarkBg4          = "#7c6f64"
-	gruvboxDarkFg0          = "#fbf1c7"
-	gruvboxDarkFg1          = "#ebdbb2"
-	gruvboxDarkFg2          = "#d5c4a1"
-	gruvboxDarkFg3          = "#bdae93"
-	gruvboxDarkFg4          = "#a89984"
-	gruvboxDarkGray         = "#928374"
-	gruvboxDarkRed          = "#cc241d"
-	gruvboxDarkRedBright    = "#fb4934"
-	gruvboxDarkGreen        = "#98971a"
-	gruvboxDarkGreenBright  = "#b8bb26"
-	gruvboxDarkYellow       = "#d79921"
-	gruvboxDarkYellowBright = "#fabd2f"
-	gruvboxDarkBlue         = "#458588"
-	gruvboxDarkBlueBright   = "#83a598"
-	gruvboxDarkPurple       = "#b16286"
-	gruvboxDarkPurpleBright = "#d3869b"
-	gruvboxDarkAqua         = "#689d6a"
-	gruvboxDarkAquaBright   = "#8ec07c"
-	gruvboxDarkOrange       = "#d65d0e"
-	gruvboxDarkOrangeBright = "#fe8019"
-
-	// Light theme colors
-	gruvboxLightBg0          = "#fbf1c7"
-	gruvboxLightBg0Soft      = "#f2e5bc"
-	gruvboxLightBg1          = "#ebdbb2"
-	gruvboxLightBg2          = "#d5c4a1"
-	gruvboxLightBg3          = "#bdae93"
-	gruvboxLightBg4          = "#a89984"
-	gruvboxLightFg0          = "#282828"
-	gruvboxLightFg1          = "#3c3836"
-	gruvboxLightFg2          = "#504945"
-	gruvboxLightFg3          = "#665c54"
-	gruvboxLightFg4          = "#7c6f64"
-	gruvboxLightGray         = "#928374"
-	gruvboxLightRed          = "#9d0006"
-	gruvboxLightRedBright    = "#cc241d"
-	gruvboxLightGreen        = "#79740e"
-	gruvboxLightGreenBright  = "#98971a"
-	gruvboxLightYellow       = "#b57614"
-	gruvboxLightYellowBright = "#d79921"
-	gruvboxLightBlue         = "#076678"
-	gruvboxLightBlueBright   = "#458588"
-	gruvboxLightPurple       = "#8f3f71"
-	gruvboxLightPurpleBright = "#b16286"
-	gruvboxLightAqua         = "#427b58"
-	gruvboxLightAquaBright   = "#689d6a"
-	gruvboxLightOrange       = "#af3a03"
-	gruvboxLightOrangeBright = "#d65d0e"
-)
-
-// GruvboxTheme implements the Theme interface with Gruvbox colors.
-// It provides both dark and light variants.
-type GruvboxTheme struct {
-	BaseTheme
-}
-
-// NewGruvboxTheme creates a new instance of the Gruvbox theme.
-func NewGruvboxTheme() *GruvboxTheme {
-	theme := &GruvboxTheme{}
-
-	// Base colors
-	theme.PrimaryColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBlueBright,
-		Light: gruvboxLightBlueBright,
-	}
-	theme.SecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkPurpleBright,
-		Light: gruvboxLightPurpleBright,
-	}
-	theme.AccentColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkOrangeBright,
-		Light: gruvboxLightOrangeBright,
-	}
-
-	// Status colors
-	theme.ErrorColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkRedBright,
-		Light: gruvboxLightRedBright,
-	}
-	theme.WarningColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkYellowBright,
-		Light: gruvboxLightYellowBright,
-	}
-	theme.SuccessColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkGreenBright,
-		Light: gruvboxLightGreenBright,
-	}
-	theme.InfoColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBlueBright,
-		Light: gruvboxLightBlueBright,
-	}
-
-	// Text colors
-	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkFg1,
-		Light: gruvboxLightFg1,
-	}
-	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkFg4,
-		Light: gruvboxLightFg4,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkYellowBright,
-		Light: gruvboxLightYellowBright,
-	}
-
-	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBg0,
-		Light: gruvboxLightBg0,
-	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBg1,
-		Light: gruvboxLightBg1,
-	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBg0Soft,
-		Light: gruvboxLightBg0Soft,
-	}
-
-	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBg2,
-		Light: gruvboxLightBg2,
-	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBlueBright,
-		Light: gruvboxLightBlueBright,
-	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBg1,
-		Light: gruvboxLightBg1,
-	}
-
-	// Diff view colors
-	theme.DiffAddedColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkGreenBright,
-		Light: gruvboxLightGreenBright,
-	}
-	theme.DiffRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkRedBright,
-		Light: gruvboxLightRedBright,
-	}
-	theme.DiffContextColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkFg4,
-		Light: gruvboxLightFg4,
-	}
-	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkFg3,
-		Light: gruvboxLightFg3,
-	}
-	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkGreenBright,
-		Light: gruvboxLightGreenBright,
-	}
-	theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkRedBright,
-		Light: gruvboxLightRedBright,
-	}
-	theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#3C4C3C",  // Darker green background
-		Light: "#E8F5E9", // Light green background
-	}
-	theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#4C3C3C",  // Darker red background
-		Light: "#FFEBEE", // Light red background
-	}
-	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBg0,
-		Light: gruvboxLightBg0,
-	}
-	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkFg4,
-		Light: gruvboxLightFg4,
-	}
-	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#32432F",   // Slightly darker green
-		Light: "#C8E6C9", // Light green
-	}
-	theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#43322F",   // Slightly darker red
-		Light: "#FFCDD2", // Light red
-	}
-
-	// Markdown colors
-	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkFg1,
-		Light: gruvboxLightFg1,
-	}
-	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkYellowBright,
-		Light: gruvboxLightYellowBright,
-	}
-	theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBlueBright,
-		Light: gruvboxLightBlueBright,
-	}
-	theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkAquaBright,
-		Light: gruvboxLightAquaBright,
-	}
-	theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkGreenBright,
-		Light: gruvboxLightGreenBright,
-	}
-	theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkAquaBright,
-		Light: gruvboxLightAquaBright,
-	}
-	theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkYellowBright,
-		Light: gruvboxLightYellowBright,
-	}
-	theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkOrangeBright,
-		Light: gruvboxLightOrangeBright,
-	}
-	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBg3,
-		Light: gruvboxLightBg3,
-	}
-	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBlueBright,
-		Light: gruvboxLightBlueBright,
-	}
-	theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBlueBright,
-		Light: gruvboxLightBlueBright,
-	}
-	theme.MarkdownImageColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkPurpleBright,
-		Light: gruvboxLightPurpleBright,
-	}
-	theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkAquaBright,
-		Light: gruvboxLightAquaBright,
-	}
-	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkFg1,
-		Light: gruvboxLightFg1,
-	}
-
-	// Syntax highlighting colors
-	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkGray,
-		Light: gruvboxLightGray,
-	}
-	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkRedBright,
-		Light: gruvboxLightRedBright,
-	}
-	theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkGreenBright,
-		Light: gruvboxLightGreenBright,
-	}
-	theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkBlueBright,
-		Light: gruvboxLightBlueBright,
-	}
-	theme.SyntaxStringColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkYellowBright,
-		Light: gruvboxLightYellowBright,
-	}
-	theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkPurpleBright,
-		Light: gruvboxLightPurpleBright,
-	}
-	theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkYellow,
-		Light: gruvboxLightYellow,
-	}
-	theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkAquaBright,
-		Light: gruvboxLightAquaBright,
-	}
-	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  gruvboxDarkFg1,
-		Light: gruvboxLightFg1,
-	}
-
-	return theme
-}
-
-func init() {
-	// Register the Gruvbox theme with the theme manager
-	RegisterTheme("gruvbox", NewGruvboxTheme())
-}

internal/tui/theme/manager.go 🔗

@@ -1,118 +0,0 @@
-package theme
-
-import (
-	"fmt"
-	"slices"
-	"strings"
-	"sync"
-
-	"github.com/alecthomas/chroma/v2/styles"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/logging"
-)
-
-// Manager handles theme registration, selection, and retrieval.
-// It maintains a registry of available themes and tracks the currently active theme.
-type Manager struct {
-	themes      map[string]Theme
-	currentName string
-	mu          sync.RWMutex
-}
-
-// Global instance of the theme manager
-var globalManager = &Manager{
-	themes:      make(map[string]Theme),
-	currentName: "",
-}
-
-// RegisterTheme adds a new theme to the registry.
-// If this is the first theme registered, it becomes the default.
-func RegisterTheme(name string, theme Theme) {
-	globalManager.mu.Lock()
-	defer globalManager.mu.Unlock()
-
-	globalManager.themes[name] = theme
-
-	// If this is the first theme, make it the default
-	if globalManager.currentName == "" {
-		globalManager.currentName = name
-	}
-}
-
-// SetTheme changes the active theme to the one with the specified name.
-// Returns an error if the theme doesn't exist.
-func SetTheme(name string) error {
-	globalManager.mu.Lock()
-	defer globalManager.mu.Unlock()
-
-	delete(styles.Registry, "charm")
-	if _, exists := globalManager.themes[name]; !exists {
-		return fmt.Errorf("theme '%s' not found", name)
-	}
-
-	globalManager.currentName = name
-
-	// Update the config file using viper
-	if err := updateConfigTheme(name); err != nil {
-		// Log the error but don't fail the theme change
-		logging.Warn("Warning: Failed to update config file with new theme", "err", err)
-	}
-
-	return nil
-}
-
-// CurrentTheme returns the currently active theme.
-// If no theme is set, it returns nil.
-func CurrentTheme() Theme {
-	globalManager.mu.RLock()
-	defer globalManager.mu.RUnlock()
-
-	if globalManager.currentName == "" {
-		return nil
-	}
-
-	return globalManager.themes[globalManager.currentName]
-}
-
-// CurrentThemeName returns the name of the currently active theme.
-func CurrentThemeName() string {
-	globalManager.mu.RLock()
-	defer globalManager.mu.RUnlock()
-
-	return globalManager.currentName
-}
-
-// AvailableThemes returns a list of all registered theme names.
-func AvailableThemes() []string {
-	globalManager.mu.RLock()
-	defer globalManager.mu.RUnlock()
-
-	names := make([]string, 0, len(globalManager.themes))
-	for name := range globalManager.themes {
-		names = append(names, name)
-	}
-	slices.SortFunc(names, func(a, b string) int {
-		if a == "opencode" {
-			return -1
-		} else if b == "opencode" {
-			return 1
-		}
-		return strings.Compare(a, b)
-	})
-	return names
-}
-
-// GetTheme returns a specific theme by name.
-// Returns nil if the theme doesn't exist.
-func GetTheme(name string) Theme {
-	globalManager.mu.RLock()
-	defer globalManager.mu.RUnlock()
-
-	return globalManager.themes[name]
-}
-
-// updateConfigTheme updates the theme setting in the configuration file
-func updateConfigTheme(themeName string) error {
-	// Use the config package to update the theme
-	return config.UpdateTheme(themeName)
-}

internal/tui/theme/monokai.go 🔗

@@ -1,273 +0,0 @@
-package theme
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-// MonokaiProTheme implements the Theme interface with Monokai Pro colors.
-// It provides both dark and light variants.
-type MonokaiProTheme struct {
-	BaseTheme
-}
-
-// NewMonokaiProTheme creates a new instance of the Monokai Pro theme.
-func NewMonokaiProTheme() *MonokaiProTheme {
-	// Monokai Pro color palette (dark mode)
-	darkBackground := "#2d2a2e"
-	darkCurrentLine := "#403e41"
-	darkSelection := "#5b595c"
-	darkForeground := "#fcfcfa"
-	darkComment := "#727072"
-	darkRed := "#ff6188"
-	darkOrange := "#fc9867"
-	darkYellow := "#ffd866"
-	darkGreen := "#a9dc76"
-	darkCyan := "#78dce8"
-	darkBlue := "#ab9df2"
-	darkPurple := "#ab9df2"
-	darkBorder := "#403e41"
-
-	// Light mode colors (adapted from dark)
-	lightBackground := "#fafafa"
-	lightCurrentLine := "#f0f0f0"
-	lightSelection := "#e5e5e6"
-	lightForeground := "#2d2a2e"
-	lightComment := "#939293"
-	lightRed := "#f92672"
-	lightOrange := "#fd971f"
-	lightYellow := "#e6db74"
-	lightGreen := "#9bca65"
-	lightCyan := "#66d9ef"
-	lightBlue := "#7e75db"
-	lightPurple := "#ae81ff"
-	lightBorder := "#d3d3d3"
-
-	theme := &MonokaiProTheme{}
-
-	// Base colors
-	theme.PrimaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.SecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.AccentColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-
-	// Status colors
-	theme.ErrorColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.WarningColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SuccessColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.InfoColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-
-	// Text colors
-	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-
-	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCurrentLine,
-		Light: lightCurrentLine,
-	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  "#221f22", // Slightly darker than background
-		Light: "#ffffff", // Slightly lighter than background
-	}
-
-	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  darkBorder,
-		Light: lightBorder,
-	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  darkSelection,
-		Light: lightSelection,
-	}
-
-	// Diff view colors
-	theme.DiffAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#a9dc76",
-		Light: "#9bca65",
-	}
-	theme.DiffRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#ff6188",
-		Light: "#f92672",
-	}
-	theme.DiffContextColor = lipgloss.AdaptiveColor{
-		Dark:  "#a0a0a0",
-		Light: "#757575",
-	}
-	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  "#a0a0a0",
-		Light: "#757575",
-	}
-	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#c2e7a9",
-		Light: "#c5e0b4",
-	}
-	theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#ff8ca6",
-		Light: "#ffb3c8",
-	}
-	theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#3a4a35",
-		Light: "#e8f5e9",
-	}
-	theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#4a3439",
-		Light: "#ffebee",
-	}
-	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  "#888888",
-		Light: "#9e9e9e",
-	}
-	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#2d3a28",
-		Light: "#c8e6c9",
-	}
-	theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#3d2a2e",
-		Light: "#ffcdd2",
-	}
-
-	// Markdown colors
-	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownImageColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	// Syntax highlighting colors
-	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.SyntaxStringColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	return theme
-}
-
-func init() {
-	// Register the Monokai Pro theme with the theme manager
-	RegisterTheme("monokai", NewMonokaiProTheme())
-}

internal/tui/theme/onedark.go 🔗

@@ -1,274 +0,0 @@
-package theme
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-// OneDarkTheme implements the Theme interface with Atom's One Dark colors.
-// It provides both dark and light variants.
-type OneDarkTheme struct {
-	BaseTheme
-}
-
-// NewOneDarkTheme creates a new instance of the One Dark theme.
-func NewOneDarkTheme() *OneDarkTheme {
-	// One Dark color palette
-	// Dark mode colors from Atom One Dark
-	darkBackground := "#282c34"
-	darkCurrentLine := "#2c313c"
-	darkSelection := "#3e4451"
-	darkForeground := "#abb2bf"
-	darkComment := "#5c6370"
-	darkRed := "#e06c75"
-	darkOrange := "#d19a66"
-	darkYellow := "#e5c07b"
-	darkGreen := "#98c379"
-	darkCyan := "#56b6c2"
-	darkBlue := "#61afef"
-	darkPurple := "#c678dd"
-	darkBorder := "#3b4048"
-
-	// Light mode colors from Atom One Light
-	lightBackground := "#fafafa"
-	lightCurrentLine := "#f0f0f0"
-	lightSelection := "#e5e5e6"
-	lightForeground := "#383a42"
-	lightComment := "#a0a1a7"
-	lightRed := "#e45649"
-	lightOrange := "#da8548"
-	lightYellow := "#c18401"
-	lightGreen := "#50a14f"
-	lightCyan := "#0184bc"
-	lightBlue := "#4078f2"
-	lightPurple := "#a626a4"
-	lightBorder := "#d3d3d3"
-
-	theme := &OneDarkTheme{}
-
-	// Base colors
-	theme.PrimaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.SecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.AccentColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-
-	// Status colors
-	theme.ErrorColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.WarningColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SuccessColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.InfoColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-
-	// Text colors
-	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-
-	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCurrentLine,
-		Light: lightCurrentLine,
-	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  "#21252b", // Slightly darker than background
-		Light: "#ffffff", // Slightly lighter than background
-	}
-
-	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  darkBorder,
-		Light: lightBorder,
-	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  darkSelection,
-		Light: lightSelection,
-	}
-
-	// Diff view colors
-	theme.DiffAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#478247",
-		Light: "#2E7D32",
-	}
-	theme.DiffRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#7C4444",
-		Light: "#C62828",
-	}
-	theme.DiffContextColor = lipgloss.AdaptiveColor{
-		Dark:  "#a0a0a0",
-		Light: "#757575",
-	}
-	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  "#a0a0a0",
-		Light: "#757575",
-	}
-	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#DAFADA",
-		Light: "#A5D6A7",
-	}
-	theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#FADADD",
-		Light: "#EF9A9A",
-	}
-	theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#303A30",
-		Light: "#E8F5E9",
-	}
-	theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#3A3030",
-		Light: "#FFEBEE",
-	}
-	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  "#888888",
-		Light: "#9E9E9E",
-	}
-	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#293229",
-		Light: "#C8E6C9",
-	}
-	theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#332929",
-		Light: "#FFCDD2",
-	}
-
-	// Markdown colors
-	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownImageColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	// Syntax highlighting colors
-	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.SyntaxStringColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	return theme
-}
-
-func init() {
-	// Register the One Dark theme with the theme manager
-	RegisterTheme("onedark", NewOneDarkTheme())
-}

internal/tui/theme/opencode.go 🔗

@@ -1,277 +0,0 @@
-package theme
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-// OpenCodeTheme implements the Theme interface with OpenCode brand colors.
-// It provides both dark and light variants.
-type OpenCodeTheme struct {
-	BaseTheme
-}
-
-// NewOpenCodeTheme creates a new instance of the OpenCode theme.
-func NewOpenCodeTheme() *OpenCodeTheme {
-	// OpenCode color palette
-	// Dark mode colors
-	darkBackground := "#212121"
-	darkCurrentLine := "#252525"
-	darkSelection := "#303030"
-	darkForeground := "#e0e0e0"
-	darkComment := "#6a6a6a"
-	darkPrimary := "#fab283"   // Primary orange/gold
-	darkSecondary := "#5c9cf5" // Secondary blue
-	darkAccent := "#9d7cd8"    // Accent purple
-	darkRed := "#e06c75"       // Error red
-	darkOrange := "#f5a742"    // Warning orange
-	darkGreen := "#7fd88f"     // Success green
-	darkCyan := "#56b6c2"      // Info cyan
-	darkYellow := "#e5c07b"    // Emphasized text
-	darkBorder := "#4b4c5c"    // Border color
-
-	// Light mode colors
-	lightBackground := "#f8f8f8"
-	lightCurrentLine := "#f0f0f0"
-	lightSelection := "#e5e5e6"
-	lightForeground := "#2a2a2a"
-	lightComment := "#8a8a8a"
-	lightPrimary := "#3b7dd8"   // Primary blue
-	lightSecondary := "#7b5bb6" // Secondary purple
-	lightAccent := "#d68c27"    // Accent orange/gold
-	lightRed := "#d1383d"       // Error red
-	lightOrange := "#d68c27"    // Warning orange
-	lightGreen := "#3d9a57"     // Success green
-	lightCyan := "#318795"      // Info cyan
-	lightYellow := "#b0851f"    // Emphasized text
-	lightBorder := "#d3d3d3"    // Border color
-
-	theme := &OpenCodeTheme{}
-
-	// Base colors
-	theme.PrimaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkPrimary,
-		Light: lightPrimary,
-	}
-	theme.SecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkSecondary,
-		Light: lightSecondary,
-	}
-	theme.AccentColor = lipgloss.AdaptiveColor{
-		Dark:  darkAccent,
-		Light: lightAccent,
-	}
-
-	// Status colors
-	theme.ErrorColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.WarningColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SuccessColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.InfoColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-
-	// Text colors
-	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-
-	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCurrentLine,
-		Light: lightCurrentLine,
-	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  "#121212", // Slightly darker than background
-		Light: "#ffffff", // Slightly lighter than background
-	}
-
-	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  darkBorder,
-		Light: lightBorder,
-	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  darkPrimary,
-		Light: lightPrimary,
-	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  darkSelection,
-		Light: lightSelection,
-	}
-
-	// Diff view colors
-	theme.DiffAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#478247",
-		Light: "#2E7D32",
-	}
-	theme.DiffRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#7C4444",
-		Light: "#C62828",
-	}
-	theme.DiffContextColor = lipgloss.AdaptiveColor{
-		Dark:  "#a0a0a0",
-		Light: "#757575",
-	}
-	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  "#a0a0a0",
-		Light: "#757575",
-	}
-	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#DAFADA",
-		Light: "#A5D6A7",
-	}
-	theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#FADADD",
-		Light: "#EF9A9A",
-	}
-	theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#303A30",
-		Light: "#E8F5E9",
-	}
-	theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#3A3030",
-		Light: "#FFEBEE",
-	}
-	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  "#888888",
-		Light: "#9E9E9E",
-	}
-	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#293229",
-		Light: "#C8E6C9",
-	}
-	theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#332929",
-		Light: "#FFCDD2",
-	}
-
-	// Markdown colors
-	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
-		Dark:  darkSecondary,
-		Light: lightSecondary,
-	}
-	theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
-		Dark:  darkPrimary,
-		Light: lightPrimary,
-	}
-	theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
-		Dark:  darkAccent,
-		Light: lightAccent,
-	}
-	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
-		Dark:  darkPrimary,
-		Light: lightPrimary,
-	}
-	theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownImageColor = lipgloss.AdaptiveColor{
-		Dark:  darkPrimary,
-		Light: lightPrimary,
-	}
-	theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	// Syntax highlighting colors
-	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
-		Dark:  darkSecondary,
-		Light: lightSecondary,
-	}
-	theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
-		Dark:  darkPrimary,
-		Light: lightPrimary,
-	}
-	theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.SyntaxStringColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
-		Dark:  darkAccent,
-		Light: lightAccent,
-	}
-	theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	return theme
-}
-
-func init() {
-	// Register the OpenCode theme with the theme manager
-	RegisterTheme("opencode", NewOpenCodeTheme())
-}
-

internal/tui/theme/theme.go 🔗

@@ -1,208 +0,0 @@
-package theme
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-// Theme defines the interface for all UI themes in the application.
-// All colors must be defined as lipgloss.AdaptiveColor to support
-// both light and dark terminal backgrounds.
-type Theme interface {
-	// Base colors
-	Primary() lipgloss.AdaptiveColor
-	Secondary() lipgloss.AdaptiveColor
-	Accent() lipgloss.AdaptiveColor
-
-	// Status colors
-	Error() lipgloss.AdaptiveColor
-	Warning() lipgloss.AdaptiveColor
-	Success() lipgloss.AdaptiveColor
-	Info() lipgloss.AdaptiveColor
-
-	// Text colors
-	Text() lipgloss.AdaptiveColor
-	TextMuted() lipgloss.AdaptiveColor
-	TextEmphasized() lipgloss.AdaptiveColor
-
-	// Background colors
-	Background() lipgloss.AdaptiveColor
-	BackgroundSecondary() lipgloss.AdaptiveColor
-	BackgroundDarker() lipgloss.AdaptiveColor
-
-	// Border colors
-	BorderNormal() lipgloss.AdaptiveColor
-	BorderFocused() lipgloss.AdaptiveColor
-	BorderDim() lipgloss.AdaptiveColor
-
-	// Diff view colors
-	DiffAdded() lipgloss.AdaptiveColor
-	DiffRemoved() lipgloss.AdaptiveColor
-	DiffContext() lipgloss.AdaptiveColor
-	DiffHunkHeader() lipgloss.AdaptiveColor
-	DiffHighlightAdded() lipgloss.AdaptiveColor
-	DiffHighlightRemoved() lipgloss.AdaptiveColor
-	DiffAddedBg() lipgloss.AdaptiveColor
-	DiffRemovedBg() lipgloss.AdaptiveColor
-	DiffContextBg() lipgloss.AdaptiveColor
-	DiffLineNumber() lipgloss.AdaptiveColor
-	DiffAddedLineNumberBg() lipgloss.AdaptiveColor
-	DiffRemovedLineNumberBg() lipgloss.AdaptiveColor
-
-	// Markdown colors
-	MarkdownText() lipgloss.AdaptiveColor
-	MarkdownHeading() lipgloss.AdaptiveColor
-	MarkdownLink() lipgloss.AdaptiveColor
-	MarkdownLinkText() lipgloss.AdaptiveColor
-	MarkdownCode() lipgloss.AdaptiveColor
-	MarkdownBlockQuote() lipgloss.AdaptiveColor
-	MarkdownEmph() lipgloss.AdaptiveColor
-	MarkdownStrong() lipgloss.AdaptiveColor
-	MarkdownHorizontalRule() lipgloss.AdaptiveColor
-	MarkdownListItem() lipgloss.AdaptiveColor
-	MarkdownListEnumeration() lipgloss.AdaptiveColor
-	MarkdownImage() lipgloss.AdaptiveColor
-	MarkdownImageText() lipgloss.AdaptiveColor
-	MarkdownCodeBlock() lipgloss.AdaptiveColor
-
-	// Syntax highlighting colors
-	SyntaxComment() lipgloss.AdaptiveColor
-	SyntaxKeyword() lipgloss.AdaptiveColor
-	SyntaxFunction() lipgloss.AdaptiveColor
-	SyntaxVariable() lipgloss.AdaptiveColor
-	SyntaxString() lipgloss.AdaptiveColor
-	SyntaxNumber() lipgloss.AdaptiveColor
-	SyntaxType() lipgloss.AdaptiveColor
-	SyntaxOperator() lipgloss.AdaptiveColor
-	SyntaxPunctuation() lipgloss.AdaptiveColor
-}
-
-// BaseTheme provides a default implementation of the Theme interface
-// that can be embedded in concrete theme implementations.
-type BaseTheme struct {
-	// Base colors
-	PrimaryColor       lipgloss.AdaptiveColor
-	SecondaryColor     lipgloss.AdaptiveColor
-	AccentColor        lipgloss.AdaptiveColor
-
-	// Status colors
-	ErrorColor         lipgloss.AdaptiveColor
-	WarningColor       lipgloss.AdaptiveColor
-	SuccessColor       lipgloss.AdaptiveColor
-	InfoColor          lipgloss.AdaptiveColor
-
-	// Text colors
-	TextColor          lipgloss.AdaptiveColor
-	TextMutedColor     lipgloss.AdaptiveColor
-	TextEmphasizedColor lipgloss.AdaptiveColor
-
-	// Background colors
-	BackgroundColor    lipgloss.AdaptiveColor
-	BackgroundSecondaryColor lipgloss.AdaptiveColor
-	BackgroundDarkerColor lipgloss.AdaptiveColor
-
-	// Border colors
-	BorderNormalColor  lipgloss.AdaptiveColor
-	BorderFocusedColor lipgloss.AdaptiveColor
-	BorderDimColor     lipgloss.AdaptiveColor
-
-	// Diff view colors
-	DiffAddedColor     lipgloss.AdaptiveColor
-	DiffRemovedColor   lipgloss.AdaptiveColor
-	DiffContextColor   lipgloss.AdaptiveColor
-	DiffHunkHeaderColor lipgloss.AdaptiveColor
-	DiffHighlightAddedColor lipgloss.AdaptiveColor
-	DiffHighlightRemovedColor lipgloss.AdaptiveColor
-	DiffAddedBgColor   lipgloss.AdaptiveColor
-	DiffRemovedBgColor lipgloss.AdaptiveColor
-	DiffContextBgColor lipgloss.AdaptiveColor
-	DiffLineNumberColor lipgloss.AdaptiveColor
-	DiffAddedLineNumberBgColor lipgloss.AdaptiveColor
-	DiffRemovedLineNumberBgColor lipgloss.AdaptiveColor
-
-	// Markdown colors
-	MarkdownTextColor  lipgloss.AdaptiveColor
-	MarkdownHeadingColor lipgloss.AdaptiveColor
-	MarkdownLinkColor  lipgloss.AdaptiveColor
-	MarkdownLinkTextColor lipgloss.AdaptiveColor
-	MarkdownCodeColor  lipgloss.AdaptiveColor
-	MarkdownBlockQuoteColor lipgloss.AdaptiveColor
-	MarkdownEmphColor  lipgloss.AdaptiveColor
-	MarkdownStrongColor lipgloss.AdaptiveColor
-	MarkdownHorizontalRuleColor lipgloss.AdaptiveColor
-	MarkdownListItemColor lipgloss.AdaptiveColor
-	MarkdownListEnumerationColor lipgloss.AdaptiveColor
-	MarkdownImageColor lipgloss.AdaptiveColor
-	MarkdownImageTextColor lipgloss.AdaptiveColor
-	MarkdownCodeBlockColor lipgloss.AdaptiveColor
-
-	// Syntax highlighting colors
-	SyntaxCommentColor lipgloss.AdaptiveColor
-	SyntaxKeywordColor lipgloss.AdaptiveColor
-	SyntaxFunctionColor lipgloss.AdaptiveColor
-	SyntaxVariableColor lipgloss.AdaptiveColor
-	SyntaxStringColor  lipgloss.AdaptiveColor
-	SyntaxNumberColor  lipgloss.AdaptiveColor
-	SyntaxTypeColor    lipgloss.AdaptiveColor
-	SyntaxOperatorColor lipgloss.AdaptiveColor
-	SyntaxPunctuationColor lipgloss.AdaptiveColor
-}
-
-// Implement the Theme interface for BaseTheme
-func (t *BaseTheme) Primary() lipgloss.AdaptiveColor { return t.PrimaryColor }
-func (t *BaseTheme) Secondary() lipgloss.AdaptiveColor { return t.SecondaryColor }
-func (t *BaseTheme) Accent() lipgloss.AdaptiveColor { return t.AccentColor }
-
-func (t *BaseTheme) Error() lipgloss.AdaptiveColor { return t.ErrorColor }
-func (t *BaseTheme) Warning() lipgloss.AdaptiveColor { return t.WarningColor }
-func (t *BaseTheme) Success() lipgloss.AdaptiveColor { return t.SuccessColor }
-func (t *BaseTheme) Info() lipgloss.AdaptiveColor { return t.InfoColor }
-
-func (t *BaseTheme) Text() lipgloss.AdaptiveColor { return t.TextColor }
-func (t *BaseTheme) TextMuted() lipgloss.AdaptiveColor { return t.TextMutedColor }
-func (t *BaseTheme) TextEmphasized() lipgloss.AdaptiveColor { return t.TextEmphasizedColor }
-
-func (t *BaseTheme) Background() lipgloss.AdaptiveColor { return t.BackgroundColor }
-func (t *BaseTheme) BackgroundSecondary() lipgloss.AdaptiveColor { return t.BackgroundSecondaryColor }
-func (t *BaseTheme) BackgroundDarker() lipgloss.AdaptiveColor { return t.BackgroundDarkerColor }
-
-func (t *BaseTheme) BorderNormal() lipgloss.AdaptiveColor { return t.BorderNormalColor }
-func (t *BaseTheme) BorderFocused() lipgloss.AdaptiveColor { return t.BorderFocusedColor }
-func (t *BaseTheme) BorderDim() lipgloss.AdaptiveColor { return t.BorderDimColor }
-
-func (t *BaseTheme) DiffAdded() lipgloss.AdaptiveColor { return t.DiffAddedColor }
-func (t *BaseTheme) DiffRemoved() lipgloss.AdaptiveColor { return t.DiffRemovedColor }
-func (t *BaseTheme) DiffContext() lipgloss.AdaptiveColor { return t.DiffContextColor }
-func (t *BaseTheme) DiffHunkHeader() lipgloss.AdaptiveColor { return t.DiffHunkHeaderColor }
-func (t *BaseTheme) DiffHighlightAdded() lipgloss.AdaptiveColor { return t.DiffHighlightAddedColor }
-func (t *BaseTheme) DiffHighlightRemoved() lipgloss.AdaptiveColor { return t.DiffHighlightRemovedColor }
-func (t *BaseTheme) DiffAddedBg() lipgloss.AdaptiveColor { return t.DiffAddedBgColor }
-func (t *BaseTheme) DiffRemovedBg() lipgloss.AdaptiveColor { return t.DiffRemovedBgColor }
-func (t *BaseTheme) DiffContextBg() lipgloss.AdaptiveColor { return t.DiffContextBgColor }
-func (t *BaseTheme) DiffLineNumber() lipgloss.AdaptiveColor { return t.DiffLineNumberColor }
-func (t *BaseTheme) DiffAddedLineNumberBg() lipgloss.AdaptiveColor { return t.DiffAddedLineNumberBgColor }
-func (t *BaseTheme) DiffRemovedLineNumberBg() lipgloss.AdaptiveColor { return t.DiffRemovedLineNumberBgColor }
-
-func (t *BaseTheme) MarkdownText() lipgloss.AdaptiveColor { return t.MarkdownTextColor }
-func (t *BaseTheme) MarkdownHeading() lipgloss.AdaptiveColor { return t.MarkdownHeadingColor }
-func (t *BaseTheme) MarkdownLink() lipgloss.AdaptiveColor { return t.MarkdownLinkColor }
-func (t *BaseTheme) MarkdownLinkText() lipgloss.AdaptiveColor { return t.MarkdownLinkTextColor }
-func (t *BaseTheme) MarkdownCode() lipgloss.AdaptiveColor { return t.MarkdownCodeColor }
-func (t *BaseTheme) MarkdownBlockQuote() lipgloss.AdaptiveColor { return t.MarkdownBlockQuoteColor }
-func (t *BaseTheme) MarkdownEmph() lipgloss.AdaptiveColor { return t.MarkdownEmphColor }
-func (t *BaseTheme) MarkdownStrong() lipgloss.AdaptiveColor { return t.MarkdownStrongColor }
-func (t *BaseTheme) MarkdownHorizontalRule() lipgloss.AdaptiveColor { return t.MarkdownHorizontalRuleColor }
-func (t *BaseTheme) MarkdownListItem() lipgloss.AdaptiveColor { return t.MarkdownListItemColor }
-func (t *BaseTheme) MarkdownListEnumeration() lipgloss.AdaptiveColor { return t.MarkdownListEnumerationColor }
-func (t *BaseTheme) MarkdownImage() lipgloss.AdaptiveColor { return t.MarkdownImageColor }
-func (t *BaseTheme) MarkdownImageText() lipgloss.AdaptiveColor { return t.MarkdownImageTextColor }
-func (t *BaseTheme) MarkdownCodeBlock() lipgloss.AdaptiveColor { return t.MarkdownCodeBlockColor }
-
-func (t *BaseTheme) SyntaxComment() lipgloss.AdaptiveColor { return t.SyntaxCommentColor }
-func (t *BaseTheme) SyntaxKeyword() lipgloss.AdaptiveColor { return t.SyntaxKeywordColor }
-func (t *BaseTheme) SyntaxFunction() lipgloss.AdaptiveColor { return t.SyntaxFunctionColor }
-func (t *BaseTheme) SyntaxVariable() lipgloss.AdaptiveColor { return t.SyntaxVariableColor }
-func (t *BaseTheme) SyntaxString() lipgloss.AdaptiveColor { return t.SyntaxStringColor }
-func (t *BaseTheme) SyntaxNumber() lipgloss.AdaptiveColor { return t.SyntaxNumberColor }
-func (t *BaseTheme) SyntaxType() lipgloss.AdaptiveColor { return t.SyntaxTypeColor }
-func (t *BaseTheme) SyntaxOperator() lipgloss.AdaptiveColor { return t.SyntaxOperatorColor }
-func (t *BaseTheme) SyntaxPunctuation() lipgloss.AdaptiveColor { return t.SyntaxPunctuationColor }

internal/tui/theme/theme_test.go 🔗

@@ -1,89 +0,0 @@
-package theme
-
-import (
-	"testing"
-)
-
-func TestThemeRegistration(t *testing.T) {
-	// Get list of available themes
-	availableThemes := AvailableThemes()
-	
-	// Check if "catppuccin" theme is registered
-	catppuccinFound := false
-	for _, themeName := range availableThemes {
-		if themeName == "catppuccin" {
-			catppuccinFound = true
-			break
-		}
-	}
-	
-	if !catppuccinFound {
-		t.Errorf("Catppuccin theme is not registered")
-	}
-	
-	// Check if "gruvbox" theme is registered
-	gruvboxFound := false
-	for _, themeName := range availableThemes {
-		if themeName == "gruvbox" {
-			gruvboxFound = true
-			break
-		}
-	}
-	
-	if !gruvboxFound {
-		t.Errorf("Gruvbox theme is not registered")
-	}
-	
-	// Check if "monokai" theme is registered
-	monokaiFound := false
-	for _, themeName := range availableThemes {
-		if themeName == "monokai" {
-			monokaiFound = true
-			break
-		}
-	}
-	
-	if !monokaiFound {
-		t.Errorf("Monokai theme is not registered")
-	}
-	
-	// Try to get the themes and make sure they're not nil
-	catppuccin := GetTheme("catppuccin")
-	if catppuccin == nil {
-		t.Errorf("Catppuccin theme is nil")
-	}
-	
-	gruvbox := GetTheme("gruvbox")
-	if gruvbox == nil {
-		t.Errorf("Gruvbox theme is nil")
-	}
-	
-	monokai := GetTheme("monokai")
-	if monokai == nil {
-		t.Errorf("Monokai theme is nil")
-	}
-	
-	// Test switching theme
-	originalTheme := CurrentThemeName()
-	
-	err := SetTheme("gruvbox")
-	if err != nil {
-		t.Errorf("Failed to set theme to gruvbox: %v", err)
-	}
-	
-	if CurrentThemeName() != "gruvbox" {
-		t.Errorf("Theme not properly switched to gruvbox")
-	}
-	
-	err = SetTheme("monokai")
-	if err != nil {
-		t.Errorf("Failed to set theme to monokai: %v", err)
-	}
-	
-	if CurrentThemeName() != "monokai" {
-		t.Errorf("Theme not properly switched to monokai")
-	}
-	
-	// Switch back to original theme
-	_ = SetTheme(originalTheme)
-}

internal/tui/theme/tokyonight.go 🔗

@@ -1,274 +0,0 @@
-package theme
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-// TokyoNightTheme implements the Theme interface with Tokyo Night colors.
-// It provides both dark and light variants.
-type TokyoNightTheme struct {
-	BaseTheme
-}
-
-// NewTokyoNightTheme creates a new instance of the Tokyo Night theme.
-func NewTokyoNightTheme() *TokyoNightTheme {
-	// Tokyo Night color palette
-	// Dark mode colors
-	darkBackground := "#222436"
-	darkCurrentLine := "#1e2030"
-	darkSelection := "#2f334d"
-	darkForeground := "#c8d3f5"
-	darkComment := "#636da6"
-	darkRed := "#ff757f"
-	darkOrange := "#ff966c"
-	darkYellow := "#ffc777"
-	darkGreen := "#c3e88d"
-	darkCyan := "#86e1fc"
-	darkBlue := "#82aaff"
-	darkPurple := "#c099ff"
-	darkBorder := "#3b4261"
-
-	// Light mode colors (Tokyo Night Day)
-	lightBackground := "#e1e2e7"
-	lightCurrentLine := "#d5d6db"
-	lightSelection := "#c8c9ce"
-	lightForeground := "#3760bf"
-	lightComment := "#848cb5"
-	lightRed := "#f52a65"
-	lightOrange := "#b15c00"
-	lightYellow := "#8c6c3e"
-	lightGreen := "#587539"
-	lightCyan := "#007197"
-	lightBlue := "#2e7de9"
-	lightPurple := "#9854f1"
-	lightBorder := "#a8aecb"
-
-	theme := &TokyoNightTheme{}
-
-	// Base colors
-	theme.PrimaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.SecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.AccentColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-
-	// Status colors
-	theme.ErrorColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.WarningColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SuccessColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.InfoColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-
-	// Text colors
-	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-
-	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCurrentLine,
-		Light: lightCurrentLine,
-	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  "#191B29", // Darker background from palette
-		Light: "#f0f0f5", // Slightly lighter than background
-	}
-
-	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  darkBorder,
-		Light: lightBorder,
-	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  darkSelection,
-		Light: lightSelection,
-	}
-
-	// Diff view colors
-	theme.DiffAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#4fd6be", // teal from palette
-		Light: "#1e725c",
-	}
-	theme.DiffRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#c53b53", // red1 from palette
-		Light: "#c53b53",
-	}
-	theme.DiffContextColor = lipgloss.AdaptiveColor{
-		Dark:  "#828bb8", // fg_dark from palette
-		Light: "#7086b5",
-	}
-	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  "#828bb8", // fg_dark from palette
-		Light: "#7086b5",
-	}
-	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#b8db87", // git.add from palette
-		Light: "#4db380",
-	}
-	theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#e26a75", // git.delete from palette
-		Light: "#f52a65",
-	}
-	theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#20303b",
-		Light: "#d5e5d5",
-	}
-	theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#37222c",
-		Light: "#f7d8db",
-	}
-	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  "#545c7e", // dark3 from palette
-		Light: "#848cb5",
-	}
-	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#1b2b34",
-		Light: "#c5d5c5",
-	}
-	theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#2d1f26",
-		Light: "#e7c8cb",
-	}
-
-	// Markdown colors
-	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownImageColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	// Syntax highlighting colors
-	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.SyntaxStringColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	return theme
-}
-
-func init() {
-	// Register the Tokyo Night theme with the theme manager
-	RegisterTheme("tokyonight", NewTokyoNightTheme())
-}

internal/tui/theme/tron.go 🔗

@@ -1,276 +0,0 @@
-package theme
-
-import (
-	"github.com/charmbracelet/lipgloss"
-)
-
-// TronTheme implements the Theme interface with Tron-inspired colors.
-// It provides both dark and light variants, though Tron is primarily a dark theme.
-type TronTheme struct {
-	BaseTheme
-}
-
-// NewTronTheme creates a new instance of the Tron theme.
-func NewTronTheme() *TronTheme {
-	// Tron color palette
-	// Inspired by the Tron movie's neon aesthetic
-	darkBackground := "#0c141f"
-	darkCurrentLine := "#1a2633"
-	darkSelection := "#1a2633"
-	darkForeground := "#caf0ff"
-	darkComment := "#4d6b87"
-	darkCyan := "#00d9ff"
-	darkBlue := "#007fff"
-	darkOrange := "#ff9000"
-	darkPink := "#ff00a0"
-	darkPurple := "#b73fff"
-	darkRed := "#ff3333"
-	darkYellow := "#ffcc00"
-	darkGreen := "#00ff8f"
-	darkBorder := "#1a2633"
-
-	// Light mode approximation
-	lightBackground := "#f0f8ff"
-	lightCurrentLine := "#e0f0ff"
-	lightSelection := "#d0e8ff"
-	lightForeground := "#0c141f"
-	lightComment := "#4d6b87"
-	lightCyan := "#0097b3"
-	lightBlue := "#0066cc"
-	lightOrange := "#cc7300"
-	lightPink := "#cc0080"
-	lightPurple := "#9932cc"
-	lightRed := "#cc2929"
-	lightYellow := "#cc9900"
-	lightGreen := "#00cc72"
-	lightBorder := "#d0e8ff"
-
-	theme := &TronTheme{}
-
-	// Base colors
-	theme.PrimaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.SecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.AccentColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-
-	// Status colors
-	theme.ErrorColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.WarningColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SuccessColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.InfoColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-
-	// Text colors
-	theme.TextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.TextMutedColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.TextEmphasizedColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-
-	// Background colors
-	theme.BackgroundColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{
-		Dark:  darkCurrentLine,
-		Light: lightCurrentLine,
-	}
-	theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{
-		Dark:  "#070d14", // Slightly darker than background
-		Light: "#ffffff", // Slightly lighter than background
-	}
-
-	// Border colors
-	theme.BorderNormalColor = lipgloss.AdaptiveColor{
-		Dark:  darkBorder,
-		Light: lightBorder,
-	}
-	theme.BorderFocusedColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.BorderDimColor = lipgloss.AdaptiveColor{
-		Dark:  darkSelection,
-		Light: lightSelection,
-	}
-
-	// Diff view colors
-	theme.DiffAddedColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.DiffRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  darkRed,
-		Light: lightRed,
-	}
-	theme.DiffContextColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{
-		Dark:  "#00ff8f",
-		Light: "#a5d6a7",
-	}
-	theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{
-		Dark:  "#ff3333",
-		Light: "#ef9a9a",
-	}
-	theme.DiffAddedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#0a2a1a",
-		Light: "#e8f5e9",
-	}
-	theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#2a0a0a",
-		Light: "#ffebee",
-	}
-	theme.DiffContextBgColor = lipgloss.AdaptiveColor{
-		Dark:  darkBackground,
-		Light: lightBackground,
-	}
-	theme.DiffLineNumberColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#082015",
-		Light: "#c8e6c9",
-	}
-	theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{
-		Dark:  "#200808",
-		Light: "#ffcdd2",
-	}
-
-	// Markdown colors
-	theme.MarkdownTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-	theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownLinkColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownEmphColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.MarkdownStrongColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.MarkdownListItemColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownImageColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	// Syntax highlighting colors
-	theme.SyntaxCommentColor = lipgloss.AdaptiveColor{
-		Dark:  darkComment,
-		Light: lightComment,
-	}
-	theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{
-		Dark:  darkCyan,
-		Light: lightCyan,
-	}
-	theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{
-		Dark:  darkGreen,
-		Light: lightGreen,
-	}
-	theme.SyntaxVariableColor = lipgloss.AdaptiveColor{
-		Dark:  darkOrange,
-		Light: lightOrange,
-	}
-	theme.SyntaxStringColor = lipgloss.AdaptiveColor{
-		Dark:  darkYellow,
-		Light: lightYellow,
-	}
-	theme.SyntaxNumberColor = lipgloss.AdaptiveColor{
-		Dark:  darkBlue,
-		Light: lightBlue,
-	}
-	theme.SyntaxTypeColor = lipgloss.AdaptiveColor{
-		Dark:  darkPurple,
-		Light: lightPurple,
-	}
-	theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{
-		Dark:  darkPink,
-		Light: lightPink,
-	}
-	theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{
-		Dark:  darkForeground,
-		Light: lightForeground,
-	}
-
-	return theme
-}
-
-func init() {
-	// Register the Tron theme with the theme manager
-	RegisterTheme("tron", NewTronTheme())
-}

internal/tui/tui.go 🔗

@@ -2,167 +2,67 @@ package tui
 
 import (
 	"context"
-	"fmt"
-	"strings"
-
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/config"
-	"github.com/opencode-ai/opencode/internal/llm/agent"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/permission"
-	"github.com/opencode-ai/opencode/internal/pubsub"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/components/chat"
-	"github.com/opencode-ai/opencode/internal/tui/components/core"
-	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/page"
-	"github.com/opencode-ai/opencode/internal/tui/theme"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type keyMap struct {
-	Logs          key.Binding
-	Quit          key.Binding
-	Help          key.Binding
-	SwitchSession key.Binding
-	Commands      key.Binding
-	Filepicker    key.Binding
-	Models        key.Binding
-	SwitchTheme   key.Binding
-}
-
-type startCompactSessionMsg struct{}
 
-const (
-	quitKey = "q"
-)
-
-var keys = keyMap{
-	Logs: key.NewBinding(
-		key.WithKeys("ctrl+l"),
-		key.WithHelp("ctrl+l", "logs"),
-	),
-
-	Quit: key.NewBinding(
-		key.WithKeys("ctrl+c"),
-		key.WithHelp("ctrl+c", "quit"),
-	),
-	Help: key.NewBinding(
-		key.WithKeys("ctrl+_"),
-		key.WithHelp("ctrl+?", "toggle help"),
-	),
-
-	SwitchSession: key.NewBinding(
-		key.WithKeys("ctrl+s"),
-		key.WithHelp("ctrl+s", "switch session"),
-	),
-
-	Commands: key.NewBinding(
-		key.WithKeys("ctrl+k"),
-		key.WithHelp("ctrl+k", "commands"),
-	),
-	Filepicker: key.NewBinding(
-		key.WithKeys("ctrl+f"),
-		key.WithHelp("ctrl+f", "select files to upload"),
-	),
-	Models: key.NewBinding(
-		key.WithKeys("ctrl+o"),
-		key.WithHelp("ctrl+o", "model selection"),
-	),
-
-	SwitchTheme: key.NewBinding(
-		key.WithKeys("ctrl+t"),
-		key.WithHelp("ctrl+t", "switch theme"),
-	),
-}
-
-var helpEsc = key.NewBinding(
-	key.WithKeys("?"),
-	key.WithHelp("?", "toggle help"),
-)
-
-var returnKey = key.NewBinding(
-	key.WithKeys("esc"),
-	key.WithHelp("esc", "close"),
-)
-
-var logsKeyReturnKey = key.NewBinding(
-	key.WithKeys("esc", "backspace", quitKey),
-	key.WithHelp("esc/q", "go back"),
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/llm/tools"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
+	"github.com/charmbracelet/crush/internal/tui/components/completions"
+	"github.com/charmbracelet/crush/internal/tui/components/core/status"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
+	initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
+	"github.com/charmbracelet/crush/internal/tui/layout"
+	"github.com/charmbracelet/crush/internal/tui/page"
+	"github.com/charmbracelet/crush/internal/tui/page/chat"
+	"github.com/charmbracelet/crush/internal/tui/page/logs"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
 )
 
+// appModel represents the main application model that manages pages, dialogs, and UI state.
 type appModel struct {
-	width, height   int
-	currentPage     page.PageID
-	previousPage    page.PageID
-	pages           map[page.PageID]tea.Model
-	loadedPages     map[page.PageID]bool
-	status          core.StatusCmp
-	app             *app.App
-	selectedSession session.Session
-
-	showPermissions bool
-	permissions     dialog.PermissionDialogCmp
+	width, height int
+	keyMap        KeyMap
 
-	showHelp bool
-	help     dialog.HelpCmp
+	currentPage  page.PageID
+	previousPage page.PageID
+	pages        map[page.PageID]util.Model
+	loadedPages  map[page.PageID]bool
 
-	showQuit bool
-	quit     dialog.QuitDialog
+	status status.StatusCmp
 
-	showSessionDialog bool
-	sessionDialog     dialog.SessionDialog
+	app *app.App
 
-	showCommandDialog bool
-	commandDialog     dialog.CommandDialog
-	commands          []dialog.Command
+	dialog      dialogs.DialogCmp
+	completions completions.Completions
 
-	showModelDialog bool
-	modelDialog     dialog.ModelDialog
-
-	showInitDialog bool
-	initDialog     dialog.InitDialogCmp
-
-	showFilepicker bool
-	filepicker     dialog.FilepickerCmp
-
-	showThemeDialog bool
-	themeDialog     dialog.ThemeDialog
-
-	showMultiArgumentsDialog bool
-	multiArgumentsDialog     dialog.MultiArgumentsDialogCmp
-
-	isCompacting      bool
-	compactingMessage string
+	// Session
+	selectedSessionID string // The ID of the currently selected session
 }
 
+// Init initializes the application model and returns initial commands.
 func (a appModel) Init() tea.Cmd {
 	var cmds []tea.Cmd
 	cmd := a.pages[a.currentPage].Init()
-	a.loadedPages[a.currentPage] = true
 	cmds = append(cmds, cmd)
+	a.loadedPages[a.currentPage] = true
+
 	cmd = a.status.Init()
 	cmds = append(cmds, cmd)
-	cmd = a.quit.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.help.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.sessionDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.commandDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.modelDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.initDialog.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.filepicker.Init()
-	cmds = append(cmds, cmd)
-	cmd = a.themeDialog.Init()
-	cmds = append(cmds, cmd)
 
 	// Check if we should show the init dialog
 	cmds = append(cmds, func() tea.Msg {
@@ -173,510 +73,278 @@ func (a appModel) Init() tea.Cmd {
 				Msg:  "Failed to check init status: " + err.Error(),
 			}
 		}
-		return dialog.ShowInitDialogMsg{Show: shouldShow}
+		if shouldShow {
+			return dialogs.OpenDialogMsg{
+				Model: initDialog.NewInitDialogCmp(),
+			}
+		}
+		return nil
 	})
 
 	return tea.Batch(cmds...)
 }
 
-func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+// Update handles incoming messages and updates the application state.
+func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
+
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		msg.Height -= 1 // Make space for the status bar
-		a.width, a.height = msg.Width, msg.Height
-
-		s, _ := a.status.Update(msg)
-		a.status = s.(core.StatusCmp)
-		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
-		cmds = append(cmds, cmd)
-
-		prm, permCmd := a.permissions.Update(msg)
-		a.permissions = prm.(dialog.PermissionDialogCmp)
-		cmds = append(cmds, permCmd)
-
-		help, helpCmd := a.help.Update(msg)
-		a.help = help.(dialog.HelpCmp)
-		cmds = append(cmds, helpCmd)
-
-		session, sessionCmd := a.sessionDialog.Update(msg)
-		a.sessionDialog = session.(dialog.SessionDialog)
-		cmds = append(cmds, sessionCmd)
-
-		command, commandCmd := a.commandDialog.Update(msg)
-		a.commandDialog = command.(dialog.CommandDialog)
-		cmds = append(cmds, commandCmd)
-
-		filepicker, filepickerCmd := a.filepicker.Update(msg)
-		a.filepicker = filepicker.(dialog.FilepickerCmp)
-		cmds = append(cmds, filepickerCmd)
+		return a, a.handleWindowResize(msg)
+
+	// Completions messages
+	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
+		u, completionCmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return a, completionCmd
+
+	// Dialog messages
+	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
+		u, dialogCmd := a.dialog.Update(msg)
+		a.dialog = u.(dialogs.DialogCmp)
+		return a, dialogCmd
+	case commands.ShowArgumentsDialogMsg:
+		return a, util.CmdHandler(
+			dialogs.OpenDialogMsg{
+				Model: commands.NewCommandArgumentsDialog(
+					msg.CommandID,
+					msg.Content,
+					msg.ArgNames,
+				),
+			},
+		)
+	// Page change messages
+	case page.PageChangeMsg:
+		return a, a.moveToPage(msg.ID)
 
-		a.initDialog.SetSize(msg.Width, msg.Height)
+	// Status Messages
+	case util.InfoMsg, util.ClearStatusMsg:
+		s, statusCmd := a.status.Update(msg)
+		a.status = s.(status.StatusCmp)
+		cmds = append(cmds, statusCmd)
+		return a, tea.Batch(cmds...)
 
-		if a.showMultiArgumentsDialog {
-			a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
-			args, argsCmd := a.multiArgumentsDialog.Update(msg)
-			a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
-			cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
+	// Session
+	case cmpChat.SessionSelectedMsg:
+		a.selectedSessionID = msg.ID
+	case cmpChat.SessionClearedMsg:
+		a.selectedSessionID = ""
+	// Logs
+	case pubsub.Event[logging.LogMessage]:
+		// Send to the status component
+		s, statusCmd := a.status.Update(msg)
+		a.status = s.(status.StatusCmp)
+		cmds = append(cmds, statusCmd)
+
+		// If the current page is logs, update the logs view
+		if a.currentPage == logs.LogsPage {
+			updated, pageCmd := a.pages[a.currentPage].Update(msg)
+			a.pages[a.currentPage] = updated.(util.Model)
+			cmds = append(cmds, pageCmd)
 		}
-
-		return a, tea.Batch(cmds...)
-	// Status
-	case util.InfoMsg:
-		s, cmd := a.status.Update(msg)
-		a.status = s.(core.StatusCmp)
-		cmds = append(cmds, cmd)
 		return a, tea.Batch(cmds...)
-	case pubsub.Event[logging.LogMessage]:
-		if msg.Payload.Persist {
-			switch msg.Payload.Level {
-			case "error":
-				s, cmd := a.status.Update(util.InfoMsg{
-					Type: util.InfoTypeError,
-					Msg:  msg.Payload.Message,
-					TTL:  msg.Payload.PersistTime,
-				})
-				a.status = s.(core.StatusCmp)
-				cmds = append(cmds, cmd)
-			case "info":
-				s, cmd := a.status.Update(util.InfoMsg{
-					Type: util.InfoTypeInfo,
-					Msg:  msg.Payload.Message,
-					TTL:  msg.Payload.PersistTime,
-				})
-				a.status = s.(core.StatusCmp)
-				cmds = append(cmds, cmd)
-
-			case "warn":
-				s, cmd := a.status.Update(util.InfoMsg{
-					Type: util.InfoTypeWarn,
-					Msg:  msg.Payload.Message,
-					TTL:  msg.Payload.PersistTime,
-				})
-
-				a.status = s.(core.StatusCmp)
-				cmds = append(cmds, cmd)
-			default:
-				s, cmd := a.status.Update(util.InfoMsg{
-					Type: util.InfoTypeInfo,
-					Msg:  msg.Payload.Message,
-					TTL:  msg.Payload.PersistTime,
-				})
-				a.status = s.(core.StatusCmp)
-				cmds = append(cmds, cmd)
+	// Commands
+	case commands.SwitchSessionsMsg:
+		return a, func() tea.Msg {
+			allSessions, _ := a.app.Sessions.List(context.Background())
+			return dialogs.OpenDialogMsg{
+				Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
 			}
 		}
-	case util.ClearStatusMsg:
-		s, _ := a.status.Update(msg)
-		a.status = s.(core.StatusCmp)
 
-	// Permission
+	case commands.SwitchModelMsg:
+		return a, util.CmdHandler(
+			dialogs.OpenDialogMsg{
+				Model: models.NewModelDialogCmp(),
+			},
+		)
+	// Compact
+	case commands.CompactMsg:
+		return a, util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
+		})
+
+	// File Picker
+	case chat.OpenFilePickerMsg:
+		if a.dialog.ActiveDialogId() == filepicker.FilePickerID {
+			// If the commands dialog is already open, close it
+			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
+		}
+		return a, util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: filepicker.NewFilePickerCmp(),
+		})
+	// Permissions
 	case pubsub.Event[permission.PermissionRequest]:
-		a.showPermissions = true
-		return a, a.permissions.SetPermissions(msg.Payload)
-	case dialog.PermissionResponseMsg:
-		var cmd tea.Cmd
+		return a, util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: permissions.NewPermissionDialogCmp(msg.Payload),
+		})
+	case permissions.PermissionResponseMsg:
 		switch msg.Action {
-		case dialog.PermissionAllow:
+		case permissions.PermissionAllow:
 			a.app.Permissions.Grant(msg.Permission)
-		case dialog.PermissionAllowForSession:
-			a.app.Permissions.GrantPersistant(msg.Permission)
-		case dialog.PermissionDeny:
+		case permissions.PermissionAllowForSession:
+			a.app.Permissions.GrantPersistent(msg.Permission)
+		case permissions.PermissionDeny:
 			a.app.Permissions.Deny(msg.Permission)
 		}
-		a.showPermissions = false
-		return a, cmd
-
-	case page.PageChangeMsg:
-		return a, a.moveToPage(msg.ID)
-
-	case dialog.CloseQuitMsg:
-		a.showQuit = false
-		return a, nil
-
-	case dialog.CloseSessionDialogMsg:
-		a.showSessionDialog = false
 		return a, nil
-
-	case dialog.CloseCommandDialogMsg:
-		a.showCommandDialog = false
-		return a, nil
-
-	case startCompactSessionMsg:
-		// Start compacting the current session
-		a.isCompacting = true
-		a.compactingMessage = "Starting summarization..."
-
-		if a.selectedSession.ID == "" {
-			a.isCompacting = false
-			return a, util.ReportWarn("No active session to summarize")
-		}
-
-		// Start the summarization process
-		return a, func() tea.Msg {
-			ctx := context.Background()
-			a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
-			return nil
-		}
-
+	// Agent Events
 	case pubsub.Event[agent.AgentEvent]:
 		payload := msg.Payload
-		if payload.Error != nil {
-			a.isCompacting = false
-			return a, util.ReportError(payload.Error)
-		}
-
-		a.compactingMessage = payload.Progress
-
-		if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
-			a.isCompacting = false
-			return a, util.ReportInfo("Session summarization complete")
-		} else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
-			model := a.app.CoderAgent.Model()
-			contextWindow := model.ContextWindow
-			tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
-			if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
-				return a, util.CmdHandler(startCompactSessionMsg{})
-			}
-		}
-		// Continue listening for events
-		return a, nil
-
-	case dialog.CloseThemeDialogMsg:
-		a.showThemeDialog = false
-		return a, nil
-
-	case dialog.ThemeChangedMsg:
-		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
-		a.showThemeDialog = false
-		return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
-
-	case dialog.CloseModelDialogMsg:
-		a.showModelDialog = false
-		return a, nil
-
-	case dialog.ModelSelectedMsg:
-		a.showModelDialog = false
 
-		model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
-		if err != nil {
-			return a, util.ReportError(err)
+		// Forward agent events to dialogs
+		if a.dialog.HasDialogs() && a.dialog.ActiveDialogId() == compact.CompactDialogID {
+			u, dialogCmd := a.dialog.Update(payload)
+			a.dialog = u.(dialogs.DialogCmp)
+			cmds = append(cmds, dialogCmd)
 		}
 
-		return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
-
-	case dialog.ShowInitDialogMsg:
-		a.showInitDialog = msg.Show
-		return a, nil
-
-	case dialog.CloseInitDialogMsg:
-		a.showInitDialog = false
-		if msg.Initialize {
-			// Run the initialization command
-			for _, cmd := range a.commands {
-				if cmd.ID == "init" {
-					// Mark the project as initialized
-					if err := config.MarkProjectInitialized(); err != nil {
-						return a, util.ReportError(err)
-					}
-					return a, cmd.Handler(cmd)
+		// Handle auto-compact logic
+		if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
+			// Get current session to check token usage
+			session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
+			if err == nil {
+				model := a.app.CoderAgent.Model()
+				contextWindow := model.ContextWindow
+				tokens := session.CompletionTokens + session.PromptTokens
+				if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
+					// Show compact confirmation dialog
+					cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
+						Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
+					}))
 				}
 			}
-		} else {
-			// Mark the project as initialized without running the command
-			if err := config.MarkProjectInitialized(); err != nil {
-				return a, util.ReportError(err)
-			}
 		}
-		return a, nil
-
-	case chat.SessionSelectedMsg:
-		a.selectedSession = msg
-		a.sessionDialog.SetSelectedSession(msg.ID)
 
-	case pubsub.Event[session.Session]:
-		if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
-			a.selectedSession = msg.Payload
-		}
-	case dialog.SessionSelectedMsg:
-		a.showSessionDialog = false
-		if a.currentPage == page.ChatPage {
-			return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
-		}
-		return a, nil
-
-	case dialog.CommandSelectedMsg:
-		a.showCommandDialog = false
-		// Execute the command handler if available
-		if msg.Command.Handler != nil {
-			return a, msg.Command.Handler(msg.Command)
-		}
-		return a, util.ReportInfo("Command selected: " + msg.Command.Title)
-
-	case dialog.ShowMultiArgumentsDialogMsg:
-		// Show multi-arguments dialog
-		a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
-		a.showMultiArgumentsDialog = true
-		return a, a.multiArgumentsDialog.Init()
-
-	case dialog.CloseMultiArgumentsDialogMsg:
-		// Close multi-arguments dialog
-		a.showMultiArgumentsDialog = false
-
-		// If submitted, replace all named arguments and run the command
-		if msg.Submit {
-			content := msg.Content
-			
-			// Replace each named argument with its value
-			for name, value := range msg.Args {
-				placeholder := "$" + name
-				content = strings.ReplaceAll(content, placeholder, value)
-			}
-
-			// Execute the command with arguments
-			return a, util.CmdHandler(dialog.CommandRunCustomMsg{
-				Content: content,
-				Args:    msg.Args,
+		return a, tea.Batch(cmds...)
+	// Key Press Messages
+	case tea.KeyPressMsg:
+		if msg.String() == "ctrl+t" {
+			go a.app.Permissions.Request(permission.CreatePermissionRequest{
+				SessionID: "123",
+				ToolName:  "bash",
+				Action:    "execute",
+				Params: tools.BashPermissionsParams{
+					Command: "ls -la",
+				},
 			})
 		}
-		return a, nil
-
-	case tea.KeyMsg:
-		// If multi-arguments dialog is open, let it handle the key press first
-		if a.showMultiArgumentsDialog {
-			args, cmd := a.multiArgumentsDialog.Update(msg)
-			a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
-			return a, cmd
-		}
-
-		switch {
+		return a, a.handleKeyPressMsg(msg)
+	}
+	s, _ := a.status.Update(msg)
+	a.status = s.(status.StatusCmp)
+	updated, cmd := a.pages[a.currentPage].Update(msg)
+	a.pages[a.currentPage] = updated.(util.Model)
+	if a.dialog.HasDialogs() {
+		u, dialogCmd := a.dialog.Update(msg)
+		a.dialog = u.(dialogs.DialogCmp)
+		cmds = append(cmds, dialogCmd)
+	}
+	cmds = append(cmds, cmd)
+	return a, tea.Batch(cmds...)
+}
 
-		case key.Matches(msg, keys.Quit):
-			a.showQuit = !a.showQuit
-			if a.showHelp {
-				a.showHelp = false
-			}
-			if a.showSessionDialog {
-				a.showSessionDialog = false
-			}
-			if a.showCommandDialog {
-				a.showCommandDialog = false
-			}
-			if a.showFilepicker {
-				a.showFilepicker = false
-				a.filepicker.ToggleFilepicker(a.showFilepicker)
-			}
-			if a.showModelDialog {
-				a.showModelDialog = false
-			}
-			if a.showMultiArgumentsDialog {
-				a.showMultiArgumentsDialog = false
-			}
-			return a, nil
-		case key.Matches(msg, keys.SwitchSession):
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
-				// Load sessions and show the dialog
-				sessions, err := a.app.Sessions.List(context.Background())
-				if err != nil {
-					return a, util.ReportError(err)
-				}
-				if len(sessions) == 0 {
-					return a, util.ReportWarn("No sessions available")
-				}
-				a.sessionDialog.SetSessions(sessions)
-				a.showSessionDialog = true
-				return a, nil
-			}
-			return a, nil
-		case key.Matches(msg, keys.Commands):
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
-				// Show commands dialog
-				if len(a.commands) == 0 {
-					return a, util.ReportWarn("No commands available")
-				}
-				a.commandDialog.SetCommands(a.commands)
-				a.showCommandDialog = true
-				return a, nil
-			}
-			return a, nil
-		case key.Matches(msg, keys.Models):
-			if a.showModelDialog {
-				a.showModelDialog = false
-				return a, nil
-			}
-			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
-				a.showModelDialog = true
-				return a, nil
-			}
-			return a, nil
-		case key.Matches(msg, keys.SwitchTheme):
-			if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
-				// Show theme switcher dialog
-				a.showThemeDialog = true
-				// Theme list is dynamically loaded by the dialog component
-				return a, a.themeDialog.Init()
-			}
-			return a, nil
-		case key.Matches(msg, returnKey) || key.Matches(msg):
-			if msg.String() == quitKey {
-				if a.currentPage == page.LogsPage {
-					return a, a.moveToPage(page.ChatPage)
-				}
-			} else if !a.filepicker.IsCWDFocused() {
-				if a.showQuit {
-					a.showQuit = !a.showQuit
-					return a, nil
-				}
-				if a.showHelp {
-					a.showHelp = !a.showHelp
-					return a, nil
-				}
-				if a.showInitDialog {
-					a.showInitDialog = false
-					// Mark the project as initialized without running the command
-					if err := config.MarkProjectInitialized(); err != nil {
-						return a, util.ReportError(err)
-					}
-					return a, nil
-				}
-				if a.showFilepicker {
-					a.showFilepicker = false
-					a.filepicker.ToggleFilepicker(a.showFilepicker)
-					return a, nil
-				}
-				if a.currentPage == page.LogsPage {
-					return a, a.moveToPage(page.ChatPage)
-				}
-			}
-		case key.Matches(msg, keys.Logs):
-			return a, a.moveToPage(page.LogsPage)
-		case key.Matches(msg, keys.Help):
-			if a.showQuit {
-				return a, nil
-			}
-			a.showHelp = !a.showHelp
-			return a, nil
-		case key.Matches(msg, helpEsc):
-			if a.app.CoderAgent.IsBusy() {
-				if a.showQuit {
-					return a, nil
-				}
-				a.showHelp = !a.showHelp
-				return a, nil
-			}
-		case key.Matches(msg, keys.Filepicker):
-			a.showFilepicker = !a.showFilepicker
-			a.filepicker.ToggleFilepicker(a.showFilepicker)
-			return a, nil
-		}
-	default:
-		f, filepickerCmd := a.filepicker.Update(msg)
-		a.filepicker = f.(dialog.FilepickerCmp)
-		cmds = append(cmds, filepickerCmd)
+// handleWindowResize processes window resize events and updates all components.
+func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+	var cmds []tea.Cmd
+	msg.Height -= 2 // Make space for the status bar
+	a.width, a.height = msg.Width, msg.Height
 
-	}
+	// Update status bar
+	s, cmd := a.status.Update(msg)
+	a.status = s.(status.StatusCmp)
+	cmds = append(cmds, cmd)
 
-	if a.showFilepicker {
-		f, filepickerCmd := a.filepicker.Update(msg)
-		a.filepicker = f.(dialog.FilepickerCmp)
-		cmds = append(cmds, filepickerCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
+	// Update the current page
+	updated, cmd := a.pages[a.currentPage].Update(msg)
+	a.pages[a.currentPage] = updated.(util.Model)
+	cmds = append(cmds, cmd)
 
-	if a.showQuit {
-		q, quitCmd := a.quit.Update(msg)
-		a.quit = q.(dialog.QuitDialog)
-		cmds = append(cmds, quitCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
-	if a.showPermissions {
-		d, permissionsCmd := a.permissions.Update(msg)
-		a.permissions = d.(dialog.PermissionDialogCmp)
-		cmds = append(cmds, permissionsCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
+	// Update the dialogs
+	dialog, cmd := a.dialog.Update(msg)
+	a.dialog = dialog.(dialogs.DialogCmp)
+	cmds = append(cmds, cmd)
 
-	if a.showSessionDialog {
-		d, sessionCmd := a.sessionDialog.Update(msg)
-		a.sessionDialog = d.(dialog.SessionDialog)
-		cmds = append(cmds, sessionCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
-		}
-	}
+	return tea.Batch(cmds...)
+}
 
-	if a.showCommandDialog {
-		d, commandCmd := a.commandDialog.Update(msg)
-		a.commandDialog = d.(dialog.CommandDialog)
-		cmds = append(cmds, commandCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
+// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
+func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+	switch {
+	// completions
+	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
+		u, cmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return cmd
+
+	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
+		u, cmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return cmd
+	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
+		u, cmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return cmd
+	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
+		u, cmd := a.completions.Update(msg)
+		a.completions = u.(completions.Completions)
+		return cmd
+	// dialogs
+	case key.Matches(msg, a.keyMap.Quit):
+		if a.dialog.ActiveDialogId() == quit.QuitDialogID {
+			// if the quit dialog is already open, close the app
+			return tea.Quit
 		}
-	}
-
-	if a.showModelDialog {
-		d, modelCmd := a.modelDialog.Update(msg)
-		a.modelDialog = d.(dialog.ModelDialog)
-		cmds = append(cmds, modelCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
+		return util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: quit.NewQuitDialog(),
+		})
+
+	case key.Matches(msg, a.keyMap.Commands):
+		if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
+			// If the commands dialog is already open, close it
+			return util.CmdHandler(dialogs.CloseDialogMsg{})
 		}
-	}
-
-	if a.showInitDialog {
-		d, initCmd := a.initDialog.Update(msg)
-		a.initDialog = d.(dialog.InitDialogCmp)
-		cmds = append(cmds, initCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
+		return util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: commands.NewCommandDialog(a.selectedSessionID),
+		})
+	case key.Matches(msg, a.keyMap.Sessions):
+		if a.dialog.ActiveDialogId() == sessions.SessionsDialogID {
+			// If the sessions dialog is already open, close it
+			return util.CmdHandler(dialogs.CloseDialogMsg{})
 		}
-	}
-
-	if a.showThemeDialog {
-		d, themeCmd := a.themeDialog.Update(msg)
-		a.themeDialog = d.(dialog.ThemeDialog)
-		cmds = append(cmds, themeCmd)
-		// Only block key messages send all other messages down
-		if _, ok := msg.(tea.KeyMsg); ok {
-			return a, tea.Batch(cmds...)
+		var cmds []tea.Cmd
+		if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
+			// If the commands dialog is open, close it first
+			cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
 		}
-	}
-
-	s, _ := a.status.Update(msg)
-	a.status = s.(core.StatusCmp)
-	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
-	cmds = append(cmds, cmd)
-	return a, tea.Batch(cmds...)
-}
-
-// RegisterCommand adds a command to the command dialog
-func (a *appModel) RegisterCommand(cmd dialog.Command) {
-	a.commands = append(a.commands, cmd)
-}
+		cmds = append(cmds,
+			func() tea.Msg {
+				allSessions, _ := a.app.Sessions.List(context.Background())
+				return dialogs.OpenDialogMsg{
+					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
+				}
+			},
+		)
+		return tea.Sequence(cmds...)
+	// Page navigation
+	case key.Matches(msg, a.keyMap.Logs):
+		return a.moveToPage(logs.LogsPage)
 
-func (a *appModel) findCommand(id string) (dialog.Command, bool) {
-	for _, cmd := range a.commands {
-		if cmd.ID == id {
-			return cmd, true
+	default:
+		if a.dialog.HasDialogs() {
+			u, dialogCmd := a.dialog.Update(msg)
+			a.dialog = u.(dialogs.DialogCmp)
+			return dialogCmd
+		} else {
+			updated, cmd := a.pages[a.currentPage].Update(msg)
+			a.pages[a.currentPage] = updated.(util.Model)
+			return cmd
 		}
 	}
-	return dialog.Command{}, false
 }
 
+// moveToPage handles navigation between different pages in the application.
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	if a.app.CoderAgent.IsBusy() {
 		// For now we don't move to any page if the agent is busy
@@ -699,266 +367,68 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
-func (a appModel) View() string {
+// View renders the complete application interface including pages, dialogs, and overlays.
+func (a *appModel) View() tea.View {
+	pageView := a.pages[a.currentPage].View()
 	components := []string{
-		a.pages[a.currentPage].View(),
+		pageView.String(),
 	}
-
-	components = append(components, a.status.View())
+	components = append(components, a.status.View().String())
 
 	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
-
-	if a.showPermissions {
-		overlay := a.permissions.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showFilepicker {
-		overlay := a.filepicker.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-
-	}
-
-	// Show compacting status overlay
-	if a.isCompacting {
-		t := theme.CurrentTheme()
-		style := lipgloss.NewStyle().
-			Border(lipgloss.RoundedBorder()).
-			BorderForeground(t.BorderFocused()).
-			BorderBackground(t.Background()).
-			Padding(1, 2).
-			Background(t.Background()).
-			Foreground(t.Text())
-
-		overlay := style.Render("Summarizing\n" + a.compactingMessage)
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showHelp {
-		bindings := layout.KeyMapToSlice(keys)
-		if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
-			bindings = append(bindings, p.BindingKeys()...)
-		}
-		if a.showPermissions {
-			bindings = append(bindings, a.permissions.BindingKeys()...)
-		}
-		if a.currentPage == page.LogsPage {
-			bindings = append(bindings, logsKeyReturnKey)
-		}
-		if !a.app.CoderAgent.IsBusy() {
-			bindings = append(bindings, helpEsc)
-		}
-		a.help.SetBindings(bindings)
-
-		overlay := a.help.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
+	layers := []*lipgloss.Layer{
+		lipgloss.NewLayer(appView),
 	}
-
-	if a.showQuit {
-		overlay := a.quit.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
+	if a.dialog.HasDialogs() {
+		layers = append(
+			layers,
+			a.dialog.GetLayers()...,
 		)
 	}
 
-	if a.showSessionDialog {
-		overlay := a.sessionDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
+	cursor := pageView.Cursor()
+	activeView := a.dialog.ActiveView()
+	if activeView != nil {
+		cursor = activeView.Cursor()
 	}
 
-	if a.showModelDialog {
-		overlay := a.modelDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
+	if a.completions.Open() && cursor != nil {
+		cmp := a.completions.View().String()
+		x, y := a.completions.Position()
+		layers = append(
+			layers,
+			lipgloss.NewLayer(cmp).X(x).Y(y),
 		)
 	}
 
-	if a.showCommandDialog {
-		overlay := a.commandDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showInitDialog {
-		overlay := a.initDialog.View()
-		appView = layout.PlaceOverlay(
-			a.width/2-lipgloss.Width(overlay)/2,
-			a.height/2-lipgloss.Height(overlay)/2,
-			overlay,
-			appView,
-			true,
-		)
-	}
+	canvas := lipgloss.NewCanvas(
+		layers...,
+	)
 
-	if a.showThemeDialog {
-		overlay := a.themeDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	if a.showMultiArgumentsDialog {
-		overlay := a.multiArgumentsDialog.View()
-		row := lipgloss.Height(appView) / 2
-		row -= lipgloss.Height(overlay) / 2
-		col := lipgloss.Width(appView) / 2
-		col -= lipgloss.Width(overlay) / 2
-		appView = layout.PlaceOverlay(
-			col,
-			row,
-			overlay,
-			appView,
-			true,
-		)
-	}
-
-	return appView
+	t := styles.CurrentTheme()
+	view := tea.NewView(canvas.Render())
+	view.SetBackgroundColor(t.BgBase)
+	view.SetCursor(cursor)
+	return view
 }
 
+// New creates and initializes a new TUI application model.
 func New(app *app.App) tea.Model {
-	startPage := page.ChatPage
+	startPage := chat.ChatPage
 	model := &appModel{
-		currentPage:   startPage,
-		loadedPages:   make(map[page.PageID]bool),
-		status:        core.NewStatusCmp(app.LSPClients),
-		help:          dialog.NewHelpCmp(),
-		quit:          dialog.NewQuitCmp(),
-		sessionDialog: dialog.NewSessionDialogCmp(),
-		commandDialog: dialog.NewCommandDialogCmp(),
-		modelDialog:   dialog.NewModelDialogCmp(),
-		permissions:   dialog.NewPermissionDialogCmp(),
-		initDialog:    dialog.NewInitDialogCmp(),
-		themeDialog:   dialog.NewThemeDialogCmp(),
-		app:           app,
-		commands:      []dialog.Command{},
-		pages: map[page.PageID]tea.Model{
-			page.ChatPage: page.NewChatPage(app),
-			page.LogsPage: page.NewLogsPage(),
-		},
-		filepicker: dialog.NewFilepickerCmp(app),
-	}
-
-	model.RegisterCommand(dialog.Command{
-		ID:          "init",
-		Title:       "Initialize Project",
-		Description: "Create/Update the OpenCode.md memory file",
-		Handler: func(cmd dialog.Command) tea.Cmd {
-			prompt := `Please analyze this codebase and create a OpenCode.md file containing:
-1. Build/lint/test commands - especially for running a single test
-2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
-
-The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
-If there's already a opencode.md, improve it.
-If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
-			return tea.Batch(
-				util.CmdHandler(chat.SendMsg{
-					Text: prompt,
-				}),
-			)
+		currentPage: startPage,
+		app:         app,
+		status:      status.NewStatusCmp(),
+		loadedPages: make(map[page.PageID]bool),
+		keyMap:      DefaultKeyMap(),
+
+		pages: map[page.PageID]util.Model{
+			chat.ChatPage: chat.NewChatPage(app),
+			logs.LogsPage: logs.NewLogsPage(),
 		},
-	})
 
-	model.RegisterCommand(dialog.Command{
-		ID:          "compact",
-		Title:       "Compact Session",
-		Description: "Summarize the current session and create a new one with the summary",
-		Handler: func(cmd dialog.Command) tea.Cmd {
-			return func() tea.Msg {
-				return startCompactSessionMsg{}
-			}
-		},
-	})
-	// Load custom commands
-	customCommands, err := dialog.LoadCustomCommands()
-	if err != nil {
-		logging.Warn("Failed to load custom commands", "error", err)
-	} else {
-		for _, cmd := range customCommands {
-			model.RegisterCommand(cmd)
-		}
+		dialog:      dialogs.NewDialogCmp(),
+		completions: completions.New(),
 	}
 
 	return model

internal/tui/util/util.go 🔗

@@ -3,9 +3,14 @@ package util
 import (
 	"time"
 
-	tea "github.com/charmbracelet/bubbletea"
+	tea "github.com/charmbracelet/bubbletea/v2"
 )
 
+type Model interface {
+	tea.Model
+	tea.Viewable
+}
+
 func CmdHandler(msg tea.Msg) tea.Cmd {
 	return func() tea.Msg {
 		return msg

internal/version/version.go 🔗

@@ -5,7 +5,7 @@ import "runtime/debug"
 // Build-time parameters set via -ldflags
 var Version = "unknown"
 
-// A user may install pug using `go install github.com/opencode-ai/opencode@latest`.
+// A user may install pug using `go install github.com/charmbracelet/crush@latest`.
 // without -ldflags, in which case the version above is unset. As a workaround
 // we use the embedded build version that *is* set when using `go install` (and
 // is only set for `go install` and not for `go build`).

main.go 🔗

@@ -1,8 +1,13 @@
 package main
 
 import (
-	"github.com/opencode-ai/opencode/cmd"
-	"github.com/opencode-ai/opencode/internal/logging"
+	"net/http"
+	"os"
+
+	_ "net/http/pprof" // profiling
+
+	"github.com/charmbracelet/crush/cmd"
+	"github.com/charmbracelet/crush/internal/logging"
 )
 
 func main() {
@@ -10,5 +15,14 @@ func main() {
 		logging.ErrorPersist("Application terminated due to unhandled panic")
 	})
 
+	if os.Getenv("CRUSH_PROFILE") != "" {
+		go func() {
+			logging.Info("Serving pprof at localhost:6060")
+			if httpErr := http.ListenAndServe("localhost:6060", nil); httpErr != nil {
+				logging.Error("Failed to pprof listen: %v", httpErr)
+			}
+		}()
+	}
+
 	cmd.Execute()
 }

todos.md 🔗

@@ -0,0 +1,29 @@
+# Chat Page
+
+## Landing page
+
+- [x] Implement the logo landing page
+- [x] Add cwd improved
+- [x] Implement Active LSPs
+- [x] Implement Active MCPs
+
+## Dialogs
+
+- [x] Cleanup Commands
+- [x] Sessions dialog
+- [x] Models
+- [x] Move sessions and model dialog to the commands
+- [x] Add sessions shortuct
+- [ ] Add all posible actions to the commands
+
+## Investigate
+
+- [ ] Events when tool error
+- [ ] Fancy Spinner
+
+## Messages
+
+- [ ] Fix issue with numbers (padding)
+- [ ] Run tools in parallel and add the responses in parallel
+- [ ] Handle parallel permission calls
+- [ ] Weird behavior sometimes the message does not update