on main help sign in
Merge pull request #20 from charmbracelet/crush

Crush

commit8adc4c0ce8b137857d8e214f4079be551dab5b46 authorRaphael Amorim <rapha850@gmail.com> dateFri 13 Jun 2025, 14:08 (1 year ago) parent05b0570bc790a9
169 files changed+11706−12222
.opencode.json → .crush.json+4−1 .github/workflows/lint.yml+10 .gitignore+2−2 .golangci.yml+44 README.md+52−52 Taskfile.yaml+46 cmd/root.go+18−21 cmd/schema/README.md+5−5 cmd/schema/main.go+13−13 opencode-schema.json → crush-schema.json+11−11 crush.md+22 cspell.json+1 go.mod+28−20 go.sum+44−38 install+14−14 internal/app/app.go+10−30 internal/app/lsp.go+9−9 internal/completions/files-folders.go−191 internal/config/config.go+12−12 internal/config/init.go+32−1 internal/db/connect.go+3−3 internal/diff/diff.go+36−270 internal/fileutil/fileutil.go+82−32 internal/fileutil/ls.go+169 internal/format/spinner.go+3−3 internal/highlight/highlight.go+60 internal/history/file.go+2−2 internal/llm/agent/agent-tool.go+5−5 internal/llm/agent/agent.go+30−30 internal/llm/agent/mcp-tools.go+7−7 internal/llm/agent/tools.go+6−6 internal/llm/models/local.go+5−5 internal/llm/models/models.go+1−37 internal/llm/prompt/coder.go+11−11 internal/llm/prompt/prompt.go+3−3 internal/llm/prompt/prompt_test.go+4−4 internal/llm/prompt/summarizer.go+1−1 internal/llm/prompt/task.go+2−2 internal/llm/prompt/title.go+1−1 internal/llm/provider/anthropic.go+8−7 internal/llm/provider/azure.go−1 internal/llm/provider/bedrock.go+2−3 internal/llm/provider/gemini.go+4−6 internal/llm/provider/openai.go+5−5 internal/llm/provider/provider.go+5−5 internal/llm/provider/vertexai.go+1−1 internal/llm/tools/bash.go+8−8 internal/llm/tools/diagnostics.go+2−2 internal/llm/tools/edit.go+6−6 internal/llm/tools/fetch.go+3−3 internal/llm/tools/glob.go+4−4 internal/llm/tools/grep.go+2−2 internal/llm/tools/ls.go+3−97 internal/llm/tools/ls_test.go−457 internal/llm/tools/patch.go+6−6 internal/llm/tools/shell/shell.go+9−9 internal/llm/tools/sourcegraph.go+1−1 internal/llm/tools/view.go+2−2 internal/llm/tools/write.go+6−6 internal/logging/logger.go+1−1 internal/logging/writer.go+1−1 internal/lsp/client.go+3−3 internal/lsp/handlers.go+4−4 internal/lsp/language.go+1−1 internal/lsp/methods.go+1−1 internal/lsp/transport.go+2−2 internal/lsp/util/edit.go+1−1 internal/lsp/watcher/watcher.go+4−5 internal/message/content.go+1−1 internal/message/message.go+3−4 internal/permission/permission.go+4−4 internal/session/session.go+2−2 internal/tui/components/anim/anim.go+307 internal/tui/components/chat/chat.go+462−106 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+82−42 internal/tui/components/logs/table.go+124−58 internal/tui/components/util/simple-list.go−159 internal/tui/image/images.go−72 internal/tui/keys.go+56 internal/tui/layout/container.go+81−27 internal/tui/layout/layout.go+11−2 internal/tui/layout/overlay.go−169 internal/tui/layout/split.go+110−30 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+6−3 internal/tui/styles/markdown.go+3−267 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+310−840 internal/tui/util/util.go+6−1 internal/version/version.go+1−1 main.go+16−2 todos.md+29
.opencode.json → .crush.jsonrenamed+4−1
@@ -1,8 +1,11 @@
11{
22 "$schema": "./opencode-schema.json",
33 "lsp": {
4− "gopls": {
4+ "Go": {
55 "command": "gopls"
66 }
7+ },
8+ "tui": {
9+ "theme": "charm"
710 }
811}
.github/workflows/lint.ymlnew file+10
@@ -0,0 +1,10 @@
1+name: lint
2+on:
3+ push:
4+ pull_request:
5+
6+jobs:
7+ lint:
8+ uses: charmbracelet/meta/.github/workflows/lint.yml@main
9+ with:
10+ golangci_path: .golangci.yml
.gitignore+2−2
@@ -41,6 +41,6 @@ Thumbs.db
4141.env
4242.env.local
4343
44−.opencode/
44+.crush/
4545
46−opencode
46+crush
.golangci.ymlnew file+44
@@ -0,0 +1,44 @@
1+version: "2"
2+linters:
3+ enable:
4+ - bodyclose
5+ # - exhaustive
6+ # - goconst
7+ # - godot
8+ # - godox
9+ - gomoddirectives
10+ - goprintffuncname
11+ # - gosec
12+ - misspell
13+ # - nakedret
14+ # - nestif
15+ # - nilerr
16+ - noctx
17+ - nolintlint
18+ # - prealloc
19+ # - revive
20+ - rowserrcheck
21+ - sqlclosecheck
22+ - tparallel
23+ # - unconvert
24+ # - unparam
25+ - whitespace
26+ # - wrapcheck
27+ disable:
28+ - errcheck
29+ - ineffassign
30+ - staticcheck
31+ - unused
32+ exclusions:
33+ generated: lax
34+ presets:
35+ - common-false-positives
36+issues:
37+ max-issues-per-linter: 0
38+ max-same-issues: 0
39+formatters:
40+ enable:
41+ - gofumpt
42+ - goimports
43+ exclusions:
44+ generated: lax
README.md+52−52
@@ -1,4 +1,4 @@
1−# ⌬ OpenCode
1+# ⌬ Crush
22
33<p align="center"><img src="https://github.com/user-attachments/assets/9ae61ef6-70e5-4876-bc45-5bcb4e52c714" width="800"></p>
44
@@ -8,10 +8,10 @@ A powerful terminal-based AI assistant for developers, providing intelligent cod
88
99## Overview
1010
11−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.
11+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.
1212
1313<p>For a quick video overview, check out
14−<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>
14+<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>
1515
1616<a href="https://www.youtube.com/watch?v=P8luPmEa1QI"><img width="550" src="https://i3.ytimg.com/vi/P8luPmEa1QI/maxresdefault.jpg"></a><p>
1717
@@ -34,45 +34,45 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
3434
3535```bash
3636# Install the latest version
37−curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bash
37+curl -fsSL https://raw.githubusercontent.com/charmbracelet/crush/refs/heads/main/install | bash
3838
3939# Install a specific version
40−curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | VERSION=0.1.0 bash
40+curl -fsSL https://raw.githubusercontent.com/charmbracelet/crush/refs/heads/main/install | VERSION=0.1.0 bash
4141```
4242
4343### Using Homebrew (macOS and Linux)
4444
4545```bash
46−brew install opencode-ai/tap/opencode
46+brew install crush-ai/tap/crush
4747```
4848
4949### Using AUR (Arch Linux)
5050
5151```bash
5252# Using yay
53−yay -S opencode-ai-bin
53+yay -S crush-ai-bin
5454
5555# Using paru
56−paru -S opencode-ai-bin
56+paru -S crush-ai-bin
5757```
5858
5959### Using Go
6060
6161```bash
62−go install github.com/opencode-ai/opencode@latest
62+go install github.com/charmbracelet/crush@latest
6363```
6464
6565## Configuration
6666
67−OpenCode looks for configuration in the following locations:
67+Crush looks for configuration in the following locations:
6868
69−- `$HOME/.opencode.json`
70−- `$XDG_CONFIG_HOME/opencode/.opencode.json`
71−- `./.opencode.json` (local directory)
69+- `$HOME/.crush.json`
70+- `$XDG_CONFIG_HOME/crush/.crush.json`
71+- `./.crush.json` (local directory)
7272
7373### Auto Compact Feature
7474
75−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:
75+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:
7676
7777- Monitors token usage during your conversation
7878- 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:
8989
9090### Environment Variables
9191
92−You can configure OpenCode using environment variables:
92+You can configure Crush using environment variables:
9393
9494| Environment Variable | Purpose |
9595| -------------------------- | ------------------------------------------------------ |
@@ -110,7 +110,7 @@ You can configure OpenCode using environment variables:
110110
111111### Shell Configuration
112112
113−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.
113+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.
114114
115115You can override this in your configuration file:
116116
@@ -130,7 +130,7 @@ This is useful if you want to use a different shell than your default system she
130130```json
131131{
132132 "data": {
133− "directory": ".opencode"
133+ "directory": ".crush"
134134 },
135135 "providers": {
136136 "openai": {
@@ -190,7 +190,7 @@ This is useful if you want to use a different shell than your default system she
190190
191191## Supported AI Models
192192
193−OpenCode supports a variety of AI models from different providers:
193+Crush supports a variety of AI models from different providers:
194194
195195### OpenAI
196196
@@ -247,38 +247,38 @@ OpenCode supports a variety of AI models from different providers:
247247## Usage
248248
249249```bash
250−# Start OpenCode
251−opencode
250+# Start Crush
251+crush
252252
253253# Start with debug logging
254−opencode -d
254+crush -d
255255
256256# Start with a specific working directory
257−opencode -c /path/to/project
257+crush -c /path/to/project
258258```
259259
260260## Non-interactive Prompt Mode
261261
262−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.
262+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.
263263
264264```bash
265265# Run a single prompt and print the AI's response to the terminal
266−opencode -p "Explain the use of context in Go"
266+crush -p "Explain the use of context in Go"
267267
268268# Get response in JSON format
269−opencode -p "Explain the use of context in Go" -f json
269+crush -p "Explain the use of context in Go" -f json
270270
271271# Run without showing the spinner (useful for scripts)
272−opencode -p "Explain the use of context in Go" -q
272+crush -p "Explain the use of context in Go" -q
273273```
274274
275−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.
275+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.
276276
277−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.
277+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.
278278
279279### Output Formats
280280
281−OpenCode supports the following output formats in non-interactive mode:
281+Crush supports the following output formats in non-interactive mode:
282282
283283| Format | Description |
284284| ------ | ------------------------------- |
@@ -369,7 +369,7 @@ The output format is implemented as a strongly-typed `OutputFormat` in the codeb
369369
370370## AI Assistant Tools
371371
372−OpenCode's AI assistant has access to various tools to help with coding tasks:
372+Crush's AI assistant has access to various tools to help with coding tasks:
373373
374374### File and Code Tools
375375
@@ -395,7 +395,7 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
395395
396396## Architecture
397397
398−OpenCode is built with a modular architecture:
398+Crush is built with a modular architecture:
399399
400400- **cmd**: Command-line interface using Cobra
401401- **internal/app**: Core application services
@@ -410,7 +410,7 @@ OpenCode is built with a modular architecture:
410410
411411## Custom Commands
412412
413−OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
413+Crush supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
414414
415415### Creating Custom Commands
416416
@@ -419,26 +419,26 @@ Custom commands are predefined prompts stored as Markdown files in one of three
4194191. **User Commands** (prefixed with `user:`):
420420
421421 ```
422− $XDG_CONFIG_HOME/opencode/commands/
422+ $XDG_CONFIG_HOME/crush/commands/
423423 ```
424424
425− (typically `~/.config/opencode/commands/` on Linux/macOS)
425+ (typically `~/.config/crush/commands/` on Linux/macOS)
426426
427427 or
428428
429429 ```
430− $HOME/.opencode/commands/
430+ $HOME/.crush/commands/
431431 ```
432432
4334332. **Project Commands** (prefixed with `project:`):
434434
435435 ```
436− <PROJECT DIR>/.opencode/commands/
436+ <PROJECT DIR>/.crush/commands/
437437 ```
438438
439439Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
440440
441−For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
441+For example, creating a file at `~/.config/crush/commands/prime-context.md` with content:
442442
443443```markdown
444444RUN git ls-files
@@ -449,7 +449,7 @@ This creates a command called `user:prime-context`.
449449
450450### Command Arguments
451451
452−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).
452+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).
453453
454454For example:
455455
@@ -461,7 +461,7 @@ RUN git grep --author="$AUTHOR_NAME" -n .
461461RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
462462```
463463
464−When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
464+When you run a command with arguments, Crush will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
465465
466466- Clear identification of what each argument represents
467467- 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
472472You can organize commands in subdirectories:
473473
474474```
475−~/.config/opencode/commands/git/commit.md
475+~/.config/crush/commands/git/commit.md
476476```
477477
478478This 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.
487487
488488### Built-in Commands
489489
490−OpenCode includes several built-in commands:
490+Crush includes several built-in commands:
491491
492492| Command | Description |
493493| ------------------ | --------------------------------------------------------------------------------------------------- |
494−| Initialize Project | Creates or updates the OpenCode.md memory file with project-specific information |
494+| Initialize Project | Creates or updates the Crush.md memory file with project-specific information |
495495| Compact Session | Manually triggers the summarization of the current session, creating a new session with the summary |
496496
497497## MCP (Model Context Protocol)
498498
499−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.
499+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.
500500
501501### MCP Features
502502
@@ -537,7 +537,7 @@ Once configured, MCP tools are automatically available to the AI assistant along
537537
538538## LSP (Language Server Protocol)
539539
540−OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
540+Crush integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
541541
542542### LSP Features
543543
@@ -576,13 +576,13 @@ While the LSP client implementation supports the full LSP protocol (including co
576576
577577## Using a self-hosted model provider
578578
579−OpenCode can also load and use models from a self-hosted (OpenAI-like) provider.
579+Crush can also load and use models from a self-hosted (OpenAI-like) provider.
580580This is useful for developers who want to experiment with custom models.
581581
582582### Configuring a self-hosted provider
583583
584584You can use a self-hosted model by setting the `LOCAL_ENDPOINT` environment variable.
585−This will cause OpenCode to load and use the models from the specified endpoint.
585+This will cause Crush to load and use the models from the specified endpoint.
586586
587587```bash
588588LOCAL_ENDPOINT=http://localhost:1235/v1
@@ -613,19 +613,19 @@ You can also configure a self-hosted model in the configuration file under the `
613613
614614```bash
615615# Clone the repository
616−git clone https://github.com/opencode-ai/opencode.git
617−cd opencode
616+git clone https://github.com/charmbracelet/crush.git
617+cd crush
618618
619619# Build
620−go build -o opencode
620+go build -o crush
621621
622622# Run
623−./opencode
623+./crush
624624```
625625
626626## Acknowledgments
627627
628−OpenCode gratefully acknowledges the contributions and support from these key individuals:
628+Crush gratefully acknowledges the contributions and support from these key individuals:
629629
630630- [@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
631631- [@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
634634
635635## License
636636
637−OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
637+Crush is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
638638
639639## Contributing
640640
Taskfile.yamlnew file+46
@@ -0,0 +1,46 @@
1+# https://taskfile.dev
2+
3+version: "3"
4+
5+tasks:
6+ lint:
7+ desc: Run base linters
8+ cmds:
9+ - golangci-lint run
10+
11+ lint-fix:
12+ desc: Run base linters and fix issues
13+ cmds:
14+ - golangci-lint run --fix
15+
16+ test:
17+ desc: Run tests
18+ cmds:
19+ - go test ./... {{.CLI_ARGS}}
20+
21+ fmt:
22+ desc: Run gofumpt
23+ cmds:
24+ - gofumpt -w .
25+
26+ dev:
27+ desc: Run with profiling enabled
28+ env:
29+ CRUSH_PROFILE: true
30+ cmds:
31+ - go run .
32+
33+ profile:cpu:
34+ desc: 10s CPU profile
35+ cmds:
36+ - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/profile?seconds=10'
37+
38+ profile:heap:
39+ desc: Heap profile
40+ cmds:
41+ - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/heap'
42+
43+ profile:allocs:
44+ desc: Allocations profile
45+ cmds:
46+ - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/allocs'
cmd/root.go+18−21
@@ -7,44 +7,43 @@ import (
77 "sync"
88 "time"
99
10− tea "github.com/charmbracelet/bubbletea"
11− zone "github.com/lrstanley/bubblezone"
12− "github.com/opencode-ai/opencode/internal/app"
13− "github.com/opencode-ai/opencode/internal/config"
14− "github.com/opencode-ai/opencode/internal/db"
15− "github.com/opencode-ai/opencode/internal/format"
16− "github.com/opencode-ai/opencode/internal/llm/agent"
17− "github.com/opencode-ai/opencode/internal/logging"
18− "github.com/opencode-ai/opencode/internal/pubsub"
19− "github.com/opencode-ai/opencode/internal/tui"
20− "github.com/opencode-ai/opencode/internal/version"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/app"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/db"
14+ "github.com/charmbracelet/crush/internal/format"
15+ "github.com/charmbracelet/crush/internal/llm/agent"
16+ "github.com/charmbracelet/crush/internal/logging"
17+ "github.com/charmbracelet/crush/internal/pubsub"
18+ "github.com/charmbracelet/crush/internal/tui"
19+ "github.com/charmbracelet/crush/internal/version"
2120 "github.com/spf13/cobra"
2221)
2322
2423var rootCmd = &cobra.Command{
25− Use: "opencode",
24+ Use: "crush",
2625 Short: "Terminal-based AI assistant for software development",
27− Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks.
26+ Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
2827It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
2928to assist developers in writing, debugging, and understanding code directly from the terminal.`,
3029 Example: `
3130 # Run in interactive mode
32− opencode
31+ crush
3332
3433 # Run with debug logging
35− opencode -d
34+ crush -d
3635
3736 # Run with debug logging in a specific directory
38− opencode -d -c /path/to/project
37+ crush -d -c /path/to/project
3938
4039 # Print version
41− opencode -v
40+ crush -v
4241
4342 # Run a single non-interactive prompt
44− opencode -p "Explain the use of context in Go"
43+ crush -p "Explain the use of context in Go"
4544
4645 # Run a single non-interactive prompt with JSON output format
47− opencode -p "Explain the use of context in Go" -f json
46+ crush -p "Explain the use of context in Go" -f json
4847 `,
4948 RunE: func(cmd *cobra.Command, args []string) error {
5049 // 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
114113 return app.RunNonInteractive(ctx, prompt, outputFormat, quiet)
115114 }
116115
117− // Interactive mode
118116 // Set up the TUI
119− zone.NewGlobal()
120117 program := tea.NewProgram(
121118 tui.New(app),
122119 tea.WithAltScreen(),
cmd/schema/README.md+5−5
@@ -1,11 +1,11 @@
1−# OpenCode Configuration Schema Generator
1+# Crush Configuration Schema Generator
22
3−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.
3+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.
44
55## Usage
66
77```bash
8−go run cmd/schema/main.go > opencode-schema.json
8+go run cmd/schema/main.go > crush-schema.json
99```
1010
1111This will generate a JSON Schema file that can be used to validate configuration files.
@@ -24,7 +24,7 @@ The generated schema includes:
2424
2525You can use the generated schema in several ways:
2626
27−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.
27+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.
2828
29292. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema.
3030
@@ -37,7 +37,7 @@ Here's an example configuration that conforms to the schema:
3737```json
3838{
3939 "data": {
40− "directory": ".opencode"
40+ "directory": ".crush"
4141 },
4242 "debug": false,
4343 "providers": {
cmd/schema/main.go+13−13
@@ -5,8 +5,8 @@ import (
55 "fmt"
66 "os"
77
8− "github.com/opencode-ai/opencode/internal/config"
9− "github.com/opencode-ai/opencode/internal/llm/models"
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/llm/models"
1010)
1111
1212// JSONSchemaType represents a JSON Schema type
@@ -38,8 +38,8 @@ func main() {
3838func generateSchema() map[string]any {
3939 schema := map[string]any{
4040 "$schema": "http://json-schema.org/draft-07/schema#",
41− "title": "OpenCode Configuration",
42− "description": "Configuration schema for the OpenCode application",
41+ "title": "Crush Configuration",
42+ "description": "Configuration schema for the Crush application",
4343 "type": "object",
4444 "properties": map[string]any{},
4545 }
@@ -52,7 +52,7 @@ func generateSchema() map[string]any {
5252 "directory": map[string]any{
5353 "type": "string",
5454 "description": "Directory where application data is stored",
55− "default": ".opencode",
55+ "default": ".crush",
5656 },
5757 },
5858 "required": []string{"directory"},
@@ -89,12 +89,12 @@ func generateSchema() map[string]any {
8989 ".cursor/rules/",
9090 "CLAUDE.md",
9191 "CLAUDE.local.md",
92− "opencode.md",
93− "opencode.local.md",
94− "OpenCode.md",
95− "OpenCode.local.md",
96− "OPENCODE.md",
97− "OPENCODE.local.md",
92+ "crush.md",
93+ "crush.local.md",
94+ "Crush.md",
95+ "Crush.local.md",
96+ "CRUSH.md",
97+ "CRUSH.local.md",
9898 },
9999 }
100100
@@ -105,9 +105,9 @@ func generateSchema() map[string]any {
105105 "theme": map[string]any{
106106 "type": "string",
107107 "description": "TUI theme name",
108− "default": "opencode",
108+ "default": "crush",
109109 "enum": []string{
110− "opencode",
110+ "crush",
111111 "catppuccin",
112112 "dracula",
113113 "flexoki",
opencode-schema.json → crush-schema.jsonrenamed+11−11
@@ -97,7 +97,7 @@
9797 "type": "object"
9898 }
9999 },
100− "description": "Configuration schema for the OpenCode application",
100+ "description": "Configuration schema for the Crush application",
101101 "properties": {
102102 "agents": {
103103 "additionalProperties": {
@@ -216,12 +216,12 @@
216216 ".cursor/rules/",
217217 "CLAUDE.md",
218218 "CLAUDE.local.md",
219− "opencode.md",
220− "opencode.local.md",
221− "OpenCode.md",
222− "OpenCode.local.md",
223− "OPENCODE.md",
224− "OPENCODE.local.md"
219+ "crush.md",
220+ "crush.local.md",
221+ "Crush.md",
222+ "Crush.local.md",
223+ "CRUSH.md",
224+ "CRUSH.local.md"
225225 ],
226226 "description": "Context paths for the application",
227227 "items": {
@@ -233,7 +233,7 @@
233233 "description": "Storage configuration",
234234 "properties": {
235235 "directory": {
236− "default": ".opencode",
236+ "default": ".crush",
237237 "description": "Directory where application data is stored",
238238 "type": "string"
239239 }
@@ -374,10 +374,10 @@
374374 "description": "Terminal User Interface configuration",
375375 "properties": {
376376 "theme": {
377− "default": "opencode",
377+ "default": "crush",
378378 "description": "TUI theme name",
379379 "enum": [
380− "opencode",
380+ "crush",
381381 "catppuccin",
382382 "dracula",
383383 "flexoki",
@@ -397,6 +397,6 @@
397397 "type": "string"
398398 }
399399 },
400− "title": "OpenCode Configuration",
400+ "title": "Crush Configuration",
401401 "type": "object"
402402}
crush.mdnew file+22
@@ -0,0 +1,22 @@
1+# Crush Development Guide
2+
3+## Build/Test/Lint Commands
4+- **Build**: `go build .` or `go run .`
5+- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
6+- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
7+- **Format**: `task fmt` (gofumpt -w .)
8+- **Dev**: `task dev` (runs with profiling enabled)
9+
10+## Code Style Guidelines
11+- **Imports**: Use goimports formatting, group stdlib, external, internal packages
12+- **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint
13+- **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported
14+- **Types**: Prefer explicit types, use type aliases for clarity (e.g., `type AgentName string`)
15+- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping
16+- **Context**: Always pass context.Context as first parameter for operations
17+- **Interfaces**: Define interfaces in consuming packages, keep them small and focused
18+- **Structs**: Use struct embedding for composition, group related fields
19+- **Constants**: Use typed constants with iota for enums, group in const blocks
20+- **Testing**: Use testify/assert and testify/require, parallel tests with `t.Parallel()`
21+- **JSON tags**: Use snake_case for JSON field names
22+- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
cspell.jsonnew file+1
@@ -0,0 +1 @@
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+28−20
@@ -1,38 +1,50 @@
1−module github.com/opencode-ai/opencode
1+module github.com/charmbracelet/crush
22
33go 1.24.0
44
55require (
66 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
77 github.com/JohannesKaufmann/html-to-markdown v1.6.0
8+ github.com/MakeNowJust/heredoc v1.0.0
89 github.com/PuerkitoBio/goquery v1.9.2
910 github.com/alecthomas/chroma/v2 v2.15.0
1011 github.com/anthropics/anthropic-sdk-go v1.4.0
1112 github.com/aymanbagabas/go-udiff v0.2.0
1213 github.com/bmatcuk/doublestar/v4 v4.8.1
13− github.com/catppuccin/go v0.3.0
14− github.com/charmbracelet/bubbles v0.21.0
15− github.com/charmbracelet/bubbletea v1.3.5
16− github.com/charmbracelet/glamour v0.9.1
17− github.com/charmbracelet/lipgloss v1.1.0
18− github.com/charmbracelet/x/ansi v0.8.0
14+ github.com/charlievieth/fastwalk v1.0.11
15+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e
16+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94
17+ github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
18+ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
19+ github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
20+ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
21+ github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
1922 github.com/fsnotify/fsnotify v1.8.0
2023 github.com/go-logfmt/logfmt v0.6.0
2124 github.com/google/uuid v1.6.0
22− github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
2325 github.com/mark3labs/mcp-go v0.17.0
24− github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
25− github.com/muesli/reflow v0.3.0
2626 github.com/muesli/termenv v0.16.0
2727 github.com/ncruces/go-sqlite3 v0.25.0
28+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
2829 github.com/openai/openai-go v0.1.0-beta.2
2930 github.com/pressly/goose/v3 v3.24.2
31+ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
32+ github.com/sahilm/fuzzy v0.1.1
3033 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
3134 github.com/spf13/cobra v1.9.1
3235 github.com/spf13/viper v1.20.0
36+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
37+ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
3338 github.com/stretchr/testify v1.10.0
3439)
3540
41+require (
42+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
43+ github.com/disintegration/gift v1.1.2 // indirect
44+ github.com/dustin/go-humanize v1.0.1 // indirect
45+ github.com/mattn/go-isatty v0.0.20 // indirect
46+)
47+
3648require (
3749 cloud.google.com/go v0.116.0 // indirect
3850 cloud.google.com/go/auth v0.13.0 // indirect
@@ -56,15 +68,15 @@ require (
5668 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
5769 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
5870 github.com/aws/smithy-go v1.20.3 // indirect
59− github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
6071 github.com/aymerick/douceur v0.2.0 // indirect
61− github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
62− github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
72+ github.com/charmbracelet/colorprofile v0.3.1 // indirect
73+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
74+ github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620
75+ github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect
6376 github.com/charmbracelet/x/term v0.2.1 // indirect
77+ github.com/charmbracelet/x/windows v0.2.1 // indirect
6478 github.com/davecgh/go-spew v1.1.1 // indirect
65− github.com/disintegration/imaging v1.6.2
6679 github.com/dlclark/regexp2 v1.11.4 // indirect
67− github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
6880 github.com/felixge/httpsnoop v1.0.4 // indirect
6981 github.com/go-logr/logr v1.4.2 // indirect
7082 github.com/go-logr/stdr v1.2.2 // indirect
@@ -78,10 +90,7 @@ require (
7890 github.com/gorilla/websocket v1.5.3 // indirect
7991 github.com/inconshreveable/mousetrap v1.1.0 // indirect
8092 github.com/kylelemons/godebug v1.1.0 // indirect
81− github.com/lithammer/fuzzysearch v1.1.8
8293 github.com/lucasb-eyer/go-colorful v1.2.0
83− github.com/mattn/go-isatty v0.0.20 // indirect
84− github.com/mattn/go-localereader v0.0.1 // indirect
8594 github.com/mattn/go-runewidth v0.0.16 // indirect
8695 github.com/mfridman/interpolate v0.0.2 // indirect
8796 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
@@ -90,7 +99,7 @@ require (
9099 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
91100 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
92101 github.com/pmezard/go-difflib v1.0.0 // indirect
93− github.com/rivo/uniseg v0.4.7 // indirect
102+ github.com/rivo/uniseg v0.4.7
94103 github.com/rogpeppe/go-internal v1.14.1 // indirect
95104 github.com/sagikazarmark/locafero v0.7.0 // indirect
96105 github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -119,7 +128,6 @@ require (
119128 golang.org/x/net v0.39.0 // indirect
120129 golang.org/x/sync v0.13.0 // indirect
121130 golang.org/x/sys v0.32.0 // indirect
122− golang.org/x/term v0.31.0 // indirect
123131 golang.org/x/text v0.24.0 // indirect
124132 google.golang.org/genai v1.3.0
125133 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
go.sum+44−38
@@ -66,38 +66,48 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
6666github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
6767github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
6868github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
69−github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
70−github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
71−github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
72−github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
73−github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
74−github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
75−github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
76−github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
77−github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
78−github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
79−github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
80−github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
81−github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
82−github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
83−github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
84−github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
85−github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
86−github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
69+github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
70+github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
71+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
72+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
73+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174 h1:TlVW+df0rdU/osP0O8DIVS9WFOAzXe3nuiMwJR4n+CA=
74+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
75+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94 h1:QIi50k+uNTJmp2sMs+33D1m/EWr/7OPTJ8x92AY3eOc=
76+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
77+github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
78+github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
79+github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
80+github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
81+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
82+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
83+github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ=
84+github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
85+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
86+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
87+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY=
88+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9/go.mod h1:vr+xCFylsPYq2qSz+n5/jItjcK2/PgrKFMTI7VRR6CI=
89+github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
90+github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
91+github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs=
92+github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
93+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y=
94+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA=
8795github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
8896github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
97+github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
98+github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
8999github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
90100github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
91101github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
92102github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
93−github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
94−github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
103+github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
104+github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
105+github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
106+github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
95107github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
96108github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
97109github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
98110github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
99−github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
100−github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
101111github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
102112github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
103113github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -144,31 +154,20 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
144154github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
145155github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
146156github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
147−github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
148−github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
149−github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
150−github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
151157github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
152158github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
153159github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
154160github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
155161github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
156162github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
157−github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
158−github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
159−github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
160163github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
161164github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
162165github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
163166github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
164167github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
165168github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
166−github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
167−github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
168169github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
169170github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
170−github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
171−github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
172171github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
173172github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
174173github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
@@ -177,6 +176,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
177176github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
178177github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
179178github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
179+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
180+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
180181github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
181182github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
182183github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
@@ -190,15 +191,18 @@ github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJz
190191github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
191192github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
192193github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
193−github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
194194github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
195195github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
196196github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
197197github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
198198github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
199199github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
200+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
201+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
200202github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
201203github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
204+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
205+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
202206github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
203207github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
204208github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@@ -219,9 +223,14 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
219223github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
220224github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
221225github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
226+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
227+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
228+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
229+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
222230github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
223231github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
224232github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
233+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
225234github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
226235github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
227236github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -273,7 +282,6 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
273282golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
274283golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
275284golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
276−golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
277285golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
278286golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
279287golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -297,7 +305,6 @@ golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
297305golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
298306golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
299307golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
300−golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
301308golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
302309golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
303310golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -318,8 +325,6 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
318325golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
319326golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
320327golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
321−golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
322−golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
323328golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
324329golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
325330golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -349,6 +354,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
349354gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
350355gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
351356gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
357+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
352358gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
353359gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
354360modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
install+14−14
@@ -1,6 +1,6 @@
11#!/usr/bin/env bash
22set -euo pipefail
3−APP=opencode
3+APP=crush
44
55RED='\033[0;31m'
66GREEN='\033[0;32m'
@@ -36,19 +36,19 @@ case "$filename" in
3636 ;;
3737esac
3838
39−INSTALL_DIR=$HOME/.opencode/bin
39+INSTALL_DIR=$HOME/.crush/bin
4040mkdir -p "$INSTALL_DIR"
4141
4242if [ -z "$requested_version" ]; then
43− url="https://github.com/opencode-ai/opencode/releases/latest/download/$filename"
44− specific_version=$(curl -s https://api.github.com/repos/opencode-ai/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
43+ url="https://github.com/charmbracelet/crush/releases/latest/download/$filename"
44+ specific_version=$(curl -s https://api.github.com/repos/charmbracelet/crush/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
4545
4646 if [[ $? -ne 0 ]]; then
4747 echo "${RED}Failed to fetch version information${NC}"
4848 exit 1
4949 fi
5050else
51− url="https://github.com/opencode-ai/opencode/releases/download/v${requested_version}/$filename"
51+ url="https://github.com/charmbracelet/crush/releases/download/v${requested_version}/$filename"
5252 specific_version=$requested_version
5353fi
5454
@@ -67,12 +67,12 @@ print_message() {
6767}
6868
6969check_version() {
70− if command -v opencode >/dev/null 2>&1; then
71− opencode_path=$(which opencode)
70+ if command -v crush >/dev/null 2>&1; then
71+ crush_path=$(which crush)
7272
7373
7474 ## TODO: check if version is installed
75− # installed_version=$(opencode version)
75+ # installed_version=$(crush version)
7676 installed_version="0.0.1"
7777 installed_version=$(echo $installed_version | awk '{print $2}')
7878
@@ -86,11 +86,11 @@ check_version() {
8686}
8787
8888download_and_install() {
89− print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
90− mkdir -p opencodetmp && cd opencodetmp
89+ print_message info "Downloading ${ORANGE}crush ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
90+ mkdir -p crushtmp && cd crushtmp
9191 curl -# -L $url | tar xz
92− mv opencode $INSTALL_DIR
93− cd .. && rm -rf opencodetmp
92+ mv crush $INSTALL_DIR
93+ cd .. && rm -rf crushtmp
9494}
9595
9696check_version
@@ -102,9 +102,9 @@ add_to_path() {
102102 local command=$2
103103
104104 if [[ -w $config_file ]]; then
105− echo -e "\n# opencode" >> "$config_file"
105+ echo -e "\n# crush" >> "$config_file"
106106 echo "$command" >> "$config_file"
107− print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
107+ print_message info "Successfully added ${ORANGE}crush ${GREEN}to \$PATH in $config_file"
108108 else
109109 print_message warning "Manually add the directory to $config_file (or similar):"
110110 print_message info " $command"
internal/app/app.go+10−30
@@ -9,17 +9,16 @@ import (
99 "sync"
1010 "time"
1111
12− "github.com/opencode-ai/opencode/internal/config"
13− "github.com/opencode-ai/opencode/internal/db"
14− "github.com/opencode-ai/opencode/internal/format"
15− "github.com/opencode-ai/opencode/internal/history"
16− "github.com/opencode-ai/opencode/internal/llm/agent"
17− "github.com/opencode-ai/opencode/internal/logging"
18− "github.com/opencode-ai/opencode/internal/lsp"
19− "github.com/opencode-ai/opencode/internal/message"
20− "github.com/opencode-ai/opencode/internal/permission"
21− "github.com/opencode-ai/opencode/internal/session"
22− "github.com/opencode-ai/opencode/internal/tui/theme"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/db"
14+ "github.com/charmbracelet/crush/internal/format"
15+ "github.com/charmbracelet/crush/internal/history"
16+ "github.com/charmbracelet/crush/internal/llm/agent"
17+ "github.com/charmbracelet/crush/internal/logging"
18+ "github.com/charmbracelet/crush/internal/lsp"
19+ "github.com/charmbracelet/crush/internal/message"
20+ "github.com/charmbracelet/crush/internal/permission"
21+ "github.com/charmbracelet/crush/internal/session"
2322)
2423
2524type App struct {
@@ -53,9 +52,6 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
5352 LSPClients: make(map[string]*lsp.Client),
5453 }
5554
56− // Initialize theme based on configuration
57− app.initTheme()
58−
5955 // Initialize LSP clients in the background
6056 go app.initLSPClients(ctx)
6157
@@ -80,22 +76,6 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
8076 return app, nil
8177}
8278
83−// initTheme sets the application theme based on the configuration
84−func (app *App) initTheme() {
85− cfg := config.Get()
86− if cfg == nil || cfg.TUI.Theme == "" {
87− return // Use default theme
88− }
89−
90− // Try to set the theme from config
91− err := theme.SetTheme(cfg.TUI.Theme)
92− if err != nil {
93− logging.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err)
94− } else {
95− logging.Debug("Set theme from config", "theme", cfg.TUI.Theme)
96− }
97−}
98−
9979// RunNonInteractive handles the execution flow when a prompt is provided via CLI flag.
10080func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat string, quiet bool) error {
10181 logging.Info("Running in non-interactive mode")
internal/app/lsp.go+9−9
@@ -4,10 +4,10 @@ import (
44 "context"
55 "time"
66
7− "github.com/opencode-ai/opencode/internal/config"
8− "github.com/opencode-ai/opencode/internal/logging"
9− "github.com/opencode-ai/opencode/internal/lsp"
10− "github.com/opencode-ai/opencode/internal/lsp/watcher"
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/logging"
9+ "github.com/charmbracelet/crush/internal/lsp"
10+ "github.com/charmbracelet/crush/internal/lsp/watcher"
1111)
1212
1313func (app *App) initLSPClients(ctx context.Context) {
@@ -25,7 +25,7 @@ func (app *App) initLSPClients(ctx context.Context) {
2525func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
2626 // Create a specific context for initialization with a timeout
2727 logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
28−
28+
2929 // Create the LSP client
3030 lspClient, err := lsp.NewClient(ctx, command, args...)
3131 if err != nil {
@@ -36,7 +36,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
3636 // Create a longer timeout for initialization (some servers take time to start)
3737 initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
3838 defer cancel()
39−
39+
4040 // Initialize with the initialization context
4141 _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
4242 if err != nil {
@@ -57,13 +57,13 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
5757 }
5858
5959 logging.Info("LSP client initialized", "name", name)
60−
60+
6161 // Create a child context that can be canceled when the app is shutting down
6262 watchCtx, cancelFunc := context.WithCancel(ctx)
63−
63+
6464 // Create a context with the server name for better identification
6565 watchCtx = context.WithValue(watchCtx, "serverName", name)
66−
66+
6767 // Create the workspace watcher
6868 workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
6969
internal/completions/files-folders.godeleted−191
@@ -1,191 +0,0 @@
1−package completions
2−
3−import (
4− "bytes"
5− "fmt"
6− "os/exec"
7− "path/filepath"
8−
9− "github.com/lithammer/fuzzysearch/fuzzy"
10− "github.com/opencode-ai/opencode/internal/fileutil"
11− "github.com/opencode-ai/opencode/internal/logging"
12− "github.com/opencode-ai/opencode/internal/tui/components/dialog"
13−)
14−
15−type filesAndFoldersContextGroup struct {
16− prefix string
17−}
18−
19−func (cg *filesAndFoldersContextGroup) GetId() string {
20− return cg.prefix
21−}
22−
23−func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
24− return dialog.NewCompletionItem(dialog.CompletionItem{
25− Title: "Files & Folders",
26− Value: "files",
27− })
28−}
29−
30−func processNullTerminatedOutput(outputBytes []byte) []string {
31− if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 {
32− outputBytes = outputBytes[:len(outputBytes)-1]
33− }
34−
35− if len(outputBytes) == 0 {
36− return []string{}
37− }
38−
39− split := bytes.Split(outputBytes, []byte{0})
40− matches := make([]string, 0, len(split))
41−
42− for _, p := range split {
43− if len(p) == 0 {
44− continue
45− }
46−
47− path := string(p)
48− path = filepath.Join(".", path)
49−
50− if !fileutil.SkipHidden(path) {
51− matches = append(matches, path)
52− }
53− }
54−
55− return matches
56−}
57−
58−func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
59− cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case
60− cmdFzf := fileutil.GetFzfCmd(query)
61−
62− var matches []string
63− // Case 1: Both rg and fzf available
64− if cmdRg != nil && cmdFzf != nil {
65− rgPipe, err := cmdRg.StdoutPipe()
66− if err != nil {
67− return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err)
68− }
69− defer rgPipe.Close()
70−
71− cmdFzf.Stdin = rgPipe
72− var fzfOut bytes.Buffer
73− var fzfErr bytes.Buffer
74− cmdFzf.Stdout = &fzfOut
75− cmdFzf.Stderr = &fzfErr
76−
77− if err := cmdFzf.Start(); err != nil {
78− return nil, fmt.Errorf("failed to start fzf: %w", err)
79− }
80−
81− errRg := cmdRg.Run()
82− errFzf := cmdFzf.Wait()
83−
84− if errRg != nil {
85− logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg))
86− }
87−
88− if errFzf != nil {
89− if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
90− return []string{}, nil // No matches from fzf
91− }
92− return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String())
93− }
94−
95− matches = processNullTerminatedOutput(fzfOut.Bytes())
96−
97− // Case 2: Only rg available
98− } else if cmdRg != nil {
99− logging.Debug("Using Ripgrep with fuzzy match fallback for file completions")
100− var rgOut bytes.Buffer
101− var rgErr bytes.Buffer
102− cmdRg.Stdout = &rgOut
103− cmdRg.Stderr = &rgErr
104−
105− if err := cmdRg.Run(); err != nil {
106− return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String())
107− }
108−
109− allFiles := processNullTerminatedOutput(rgOut.Bytes())
110− matches = fuzzy.Find(query, allFiles)
111−
112− // Case 3: Only fzf available
113− } else if cmdFzf != nil {
114− logging.Debug("Using FZF with doublestar fallback for file completions")
115− files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
116− if err != nil {
117− return nil, fmt.Errorf("failed to list files for fzf: %w", err)
118− }
119−
120− allFiles := make([]string, 0, len(files))
121− for _, file := range files {
122− if !fileutil.SkipHidden(file) {
123− allFiles = append(allFiles, file)
124− }
125− }
126−
127− var fzfIn bytes.Buffer
128− for _, file := range allFiles {
129− fzfIn.WriteString(file)
130− fzfIn.WriteByte(0)
131− }
132−
133− cmdFzf.Stdin = &fzfIn
134− var fzfOut bytes.Buffer
135− var fzfErr bytes.Buffer
136− cmdFzf.Stdout = &fzfOut
137− cmdFzf.Stderr = &fzfErr
138−
139− if err := cmdFzf.Run(); err != nil {
140− if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
141− return []string{}, nil
142− }
143− return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String())
144− }
145−
146− matches = processNullTerminatedOutput(fzfOut.Bytes())
147−
148− // Case 4: Fallback to doublestar with fuzzy match
149− } else {
150− logging.Debug("Using doublestar with fuzzy match for file completions")
151− allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0)
152− if err != nil {
153− return nil, fmt.Errorf("failed to glob files: %w", err)
154− }
155−
156− filteredFiles := make([]string, 0, len(allFiles))
157− for _, file := range allFiles {
158− if !fileutil.SkipHidden(file) {
159− filteredFiles = append(filteredFiles, file)
160− }
161− }
162−
163− matches = fuzzy.Find(query, filteredFiles)
164− }
165−
166− return matches, nil
167−}
168−
169−func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
170− matches, err := cg.getFiles(query)
171− if err != nil {
172− return nil, err
173− }
174−
175− items := make([]dialog.CompletionItemI, 0, len(matches))
176− for _, file := range matches {
177− item := dialog.NewCompletionItem(dialog.CompletionItem{
178− Title: file,
179− Value: file,
180− })
181− items = append(items, item)
182− }
183−
184− return items, nil
185−}
186−
187−func NewFileAndFolderContextGroup() dialog.CompletionProvider {
188− return &filesAndFoldersContextGroup{
189− prefix: "file",
190− }
191−}
internal/config/config.go+12−12
@@ -9,8 +9,8 @@ import (
99 "path/filepath"
1010 "strings"
1111
12− "github.com/opencode-ai/opencode/internal/llm/models"
13− "github.com/opencode-ai/opencode/internal/logging"
12+ "github.com/charmbracelet/crush/internal/llm/models"
13+ "github.com/charmbracelet/crush/internal/logging"
1414 "github.com/spf13/viper"
1515)
1616
@@ -97,9 +97,9 @@ type Config struct {
9797
9898// Application constants
9999const (
100− defaultDataDirectory = ".opencode"
100+ defaultDataDirectory = ".crush"
101101 defaultLogLevel = "info"
102− appName = "opencode"
102+ appName = "crush"
103103
104104 MaxTokensFallbackDefault = 4096
105105)
@@ -110,12 +110,12 @@ var defaultContextPaths = []string{
110110 ".cursor/rules/",
111111 "CLAUDE.md",
112112 "CLAUDE.local.md",
113− "opencode.md",
114− "opencode.local.md",
115− "OpenCode.md",
116− "OpenCode.local.md",
117− "OPENCODE.md",
118− "OPENCODE.local.md",
113+ "crush.md",
114+ "crush.local.md",
115+ "Crush.md",
116+ "Crush.local.md",
117+ "CRUSH.md",
118+ "CRUSH.local.md",
119119}
120120
121121// Global configuration instance
@@ -159,7 +159,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
159159 if cfg.Debug {
160160 defaultLevel = slog.LevelDebug
161161 }
162− if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
162+ if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
163163 loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
164164
165165 // if file does not exist create it
@@ -221,7 +221,7 @@ func configureViper() {
221221func setDefaults(debug bool) {
222222 viper.SetDefault("data.directory", defaultDataDirectory)
223223 viper.SetDefault("contextPaths", defaultContextPaths)
224− viper.SetDefault("tui.theme", "opencode")
224+ viper.SetDefault("tui.theme", "crush")
225225 viper.SetDefault("autoCompact", true)
226226
227227 // Set default shell from environment or fallback to /bin/bash
internal/config/init.go+32−1
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "os"
66 "path/filepath"
7+ "strings"
78)
89
910const (
@@ -37,10 +38,41 @@ func ShouldShowInitDialog() (bool, error) {
3738 return false, fmt.Errorf("failed to check init flag file: %w", err)
3839 }
3940
41+ // Check if any variation of crush.md already exists in working directory
42+ crushExists, err := crushMdExists(WorkingDirectory())
43+ if err != nil {
44+ return false, fmt.Errorf("failed to check for crush.md files: %w", err)
45+ }
46+ if crushExists {
47+ // Crush.md already exists, don't show the dialog
48+ return false, nil
49+ }
50+
4051 // File doesn't exist, show the dialog
4152 return true, nil
4253}
4354
55+// crushMdExists checks if any case variation of crush.md exists in the directory
56+func crushMdExists(dir string) (bool, error) {
57+ entries, err := os.ReadDir(dir)
58+ if err != nil {
59+ return false, err
60+ }
61+
62+ for _, entry := range entries {
63+ if entry.IsDir() {
64+ continue
65+ }
66+
67+ name := strings.ToLower(entry.Name())
68+ if name == "crush.md" {
69+ return true, nil
70+ }
71+ }
72+
73+ return false, nil
74+}
75+
4476// MarkProjectInitialized marks the current project as initialized
4577func MarkProjectInitialized() error {
4678 if cfg == nil {
@@ -58,4 +90,3 @@ func MarkProjectInitialized() error {
5890
5991 return nil
6092}
61−
internal/db/connect.go+3−3
@@ -9,8 +9,8 @@ import (
99 _ "github.com/ncruces/go-sqlite3/driver"
1010 _ "github.com/ncruces/go-sqlite3/embed"
1111
12− "github.com/opencode-ai/opencode/internal/config"
13− "github.com/opencode-ai/opencode/internal/logging"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/logging"
1414
1515 "github.com/pressly/goose/v3"
1616)
@@ -23,7 +23,7 @@ func Connect() (*sql.DB, error) {
2323 if err := os.MkdirAll(dataDir, 0o700); err != nil {
2424 return nil, fmt.Errorf("failed to create data directory: %w", err)
2525 }
26− dbPath := filepath.Join(dataDir, "opencode.db")
26+ dbPath := filepath.Join(dataDir, "crush.db")
2727 // Open the SQLite database
2828 db, err := sql.Open("sqlite3", dbPath)
2929 if err != nil {
internal/diff/diff.go+36−270
@@ -1,22 +1,18 @@
11package diff
22
33import (
4− "bytes"
54 "fmt"
6− "io"
5+ "image/color"
76 "regexp"
87 "strconv"
98 "strings"
109
11− "github.com/alecthomas/chroma/v2"
12− "github.com/alecthomas/chroma/v2/formatters"
13− "github.com/alecthomas/chroma/v2/lexers"
14− "github.com/alecthomas/chroma/v2/styles"
1510 "github.com/aymanbagabas/go-udiff"
16− "github.com/charmbracelet/lipgloss"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/highlight"
13+ "github.com/charmbracelet/crush/internal/tui/styles"
14+ "github.com/charmbracelet/lipgloss/v2"
1715 "github.com/charmbracelet/x/ansi"
18− "github.com/opencode-ai/opencode/internal/config"
19− "github.com/opencode-ai/opencode/internal/tui/theme"
2016 "github.com/sergi/go-diff/diffmatchpatch"
2117)
2218
@@ -236,10 +232,7 @@ func HighlightIntralineChanges(h *Hunk) {
236232
237233 for i := 0; i < len(h.Lines); i++ {
238234 // Look for removed line followed by added line
239− if i+1 < len(h.Lines) &&
240− h.Lines[i].Kind == LineRemoved &&
241− h.Lines[i+1].Kind == LineAdded {
242−
235+ if i+1 < len(h.Lines) && h.Lines[i].Kind == LineRemoved && h.Lines[i+1].Kind == LineAdded {
243236 oldLine := h.Lines[i]
244237 newLine := h.Lines[i+1]
245238
@@ -321,241 +314,26 @@ func pairLines(lines []DiffLine) []linePair {
321314// -------------------------------------------------------------------------
322315// Syntax Highlighting
323316// -------------------------------------------------------------------------
324−
325−// SyntaxHighlight applies syntax highlighting to text based on file extension
326−func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
327− t := theme.CurrentTheme()
328−
329− // Determine the language lexer to use
330− l := lexers.Match(fileName)
331− if l == nil {
332− l = lexers.Analyse(source)
333− }
334− if l == nil {
335− l = lexers.Fallback
336− }
337− l = chroma.Coalesce(l)
338−
339− // Get the formatter
340− f := formatters.Get(formatter)
341− if f == nil {
342− f = formatters.Fallback
343− }
344−
345− // Dynamic theme based on current theme values
346− syntaxThemeXml := fmt.Sprintf(`
347− <style name="opencode-theme">
348− <!-- Base colors -->
349− <entry type="Background" style="bg:%s"/>
350− <entry type="Text" style="%s"/>
351− <entry type="Other" style="%s"/>
352− <entry type="Error" style="%s"/>
353− <!-- Keywords -->
354− <entry type="Keyword" style="%s"/>
355− <entry type="KeywordConstant" style="%s"/>
356− <entry type="KeywordDeclaration" style="%s"/>
357− <entry type="KeywordNamespace" style="%s"/>
358− <entry type="KeywordPseudo" style="%s"/>
359− <entry type="KeywordReserved" style="%s"/>
360− <entry type="KeywordType" style="%s"/>
361− <!-- Names -->
362− <entry type="Name" style="%s"/>
363− <entry type="NameAttribute" style="%s"/>
364− <entry type="NameBuiltin" style="%s"/>
365− <entry type="NameBuiltinPseudo" style="%s"/>
366− <entry type="NameClass" style="%s"/>
367− <entry type="NameConstant" style="%s"/>
368− <entry type="NameDecorator" style="%s"/>
369− <entry type="NameEntity" style="%s"/>
370− <entry type="NameException" style="%s"/>
371− <entry type="NameFunction" style="%s"/>
372− <entry type="NameLabel" style="%s"/>
373− <entry type="NameNamespace" style="%s"/>
374− <entry type="NameOther" style="%s"/>
375− <entry type="NameTag" style="%s"/>
376− <entry type="NameVariable" style="%s"/>
377− <entry type="NameVariableClass" style="%s"/>
378− <entry type="NameVariableGlobal" style="%s"/>
379− <entry type="NameVariableInstance" style="%s"/>
380− <!-- Literals -->
381− <entry type="Literal" style="%s"/>
382− <entry type="LiteralDate" style="%s"/>
383− <entry type="LiteralString" style="%s"/>
384− <entry type="LiteralStringBacktick" style="%s"/>
385− <entry type="LiteralStringChar" style="%s"/>
386− <entry type="LiteralStringDoc" style="%s"/>
387− <entry type="LiteralStringDouble" style="%s"/>
388− <entry type="LiteralStringEscape" style="%s"/>
389− <entry type="LiteralStringHeredoc" style="%s"/>
390− <entry type="LiteralStringInterpol" style="%s"/>
391− <entry type="LiteralStringOther" style="%s"/>
392− <entry type="LiteralStringRegex" style="%s"/>
393− <entry type="LiteralStringSingle" style="%s"/>
394− <entry type="LiteralStringSymbol" style="%s"/>
395− <!-- Numbers -->
396− <entry type="LiteralNumber" style="%s"/>
397− <entry type="LiteralNumberBin" style="%s"/>
398− <entry type="LiteralNumberFloat" style="%s"/>
399− <entry type="LiteralNumberHex" style="%s"/>
400− <entry type="LiteralNumberInteger" style="%s"/>
401− <entry type="LiteralNumberIntegerLong" style="%s"/>
402− <entry type="LiteralNumberOct" style="%s"/>
403− <!-- Operators -->
404− <entry type="Operator" style="%s"/>
405− <entry type="OperatorWord" style="%s"/>
406− <entry type="Punctuation" style="%s"/>
407− <!-- Comments -->
408− <entry type="Comment" style="%s"/>
409− <entry type="CommentHashbang" style="%s"/>
410− <entry type="CommentMultiline" style="%s"/>
411− <entry type="CommentSingle" style="%s"/>
412− <entry type="CommentSpecial" style="%s"/>
413− <entry type="CommentPreproc" style="%s"/>
414− <!-- Generic styles -->
415− <entry type="Generic" style="%s"/>
416− <entry type="GenericDeleted" style="%s"/>
417− <entry type="GenericEmph" style="italic %s"/>
418− <entry type="GenericError" style="%s"/>
419− <entry type="GenericHeading" style="bold %s"/>
420− <entry type="GenericInserted" style="%s"/>
421− <entry type="GenericOutput" style="%s"/>
422− <entry type="GenericPrompt" style="%s"/>
423− <entry type="GenericStrong" style="bold %s"/>
424− <entry type="GenericSubheading" style="bold %s"/>
425− <entry type="GenericTraceback" style="%s"/>
426− <entry type="GenericUnderline" style="underline"/>
427− <entry type="TextWhitespace" style="%s"/>
428−</style>
429−`,
430− getColor(t.Background()), // Background
431− getColor(t.Text()), // Text
432− getColor(t.Text()), // Other
433− getColor(t.Error()), // Error
434−
435− getColor(t.SyntaxKeyword()), // Keyword
436− getColor(t.SyntaxKeyword()), // KeywordConstant
437− getColor(t.SyntaxKeyword()), // KeywordDeclaration
438− getColor(t.SyntaxKeyword()), // KeywordNamespace
439− getColor(t.SyntaxKeyword()), // KeywordPseudo
440− getColor(t.SyntaxKeyword()), // KeywordReserved
441− getColor(t.SyntaxType()), // KeywordType
442−
443− getColor(t.Text()), // Name
444− getColor(t.SyntaxVariable()), // NameAttribute
445− getColor(t.SyntaxType()), // NameBuiltin
446− getColor(t.SyntaxVariable()), // NameBuiltinPseudo
447− getColor(t.SyntaxType()), // NameClass
448− getColor(t.SyntaxVariable()), // NameConstant
449− getColor(t.SyntaxFunction()), // NameDecorator
450− getColor(t.SyntaxVariable()), // NameEntity
451− getColor(t.SyntaxType()), // NameException
452− getColor(t.SyntaxFunction()), // NameFunction
453− getColor(t.Text()), // NameLabel
454− getColor(t.SyntaxType()), // NameNamespace
455− getColor(t.SyntaxVariable()), // NameOther
456− getColor(t.SyntaxKeyword()), // NameTag
457− getColor(t.SyntaxVariable()), // NameVariable
458− getColor(t.SyntaxVariable()), // NameVariableClass
459− getColor(t.SyntaxVariable()), // NameVariableGlobal
460− getColor(t.SyntaxVariable()), // NameVariableInstance
461−
462− getColor(t.SyntaxString()), // Literal
463− getColor(t.SyntaxString()), // LiteralDate
464− getColor(t.SyntaxString()), // LiteralString
465− getColor(t.SyntaxString()), // LiteralStringBacktick
466− getColor(t.SyntaxString()), // LiteralStringChar
467− getColor(t.SyntaxString()), // LiteralStringDoc
468− getColor(t.SyntaxString()), // LiteralStringDouble
469− getColor(t.SyntaxString()), // LiteralStringEscape
470− getColor(t.SyntaxString()), // LiteralStringHeredoc
471− getColor(t.SyntaxString()), // LiteralStringInterpol
472− getColor(t.SyntaxString()), // LiteralStringOther
473− getColor(t.SyntaxString()), // LiteralStringRegex
474− getColor(t.SyntaxString()), // LiteralStringSingle
475− getColor(t.SyntaxString()), // LiteralStringSymbol
476−
477− getColor(t.SyntaxNumber()), // LiteralNumber
478− getColor(t.SyntaxNumber()), // LiteralNumberBin
479− getColor(t.SyntaxNumber()), // LiteralNumberFloat
480− getColor(t.SyntaxNumber()), // LiteralNumberHex
481− getColor(t.SyntaxNumber()), // LiteralNumberInteger
482− getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
483− getColor(t.SyntaxNumber()), // LiteralNumberOct
484−
485− getColor(t.SyntaxOperator()), // Operator
486− getColor(t.SyntaxKeyword()), // OperatorWord
487− getColor(t.SyntaxPunctuation()), // Punctuation
488−
489− getColor(t.SyntaxComment()), // Comment
490− getColor(t.SyntaxComment()), // CommentHashbang
491− getColor(t.SyntaxComment()), // CommentMultiline
492− getColor(t.SyntaxComment()), // CommentSingle
493− getColor(t.SyntaxComment()), // CommentSpecial
494− getColor(t.SyntaxKeyword()), // CommentPreproc
495−
496− getColor(t.Text()), // Generic
497− getColor(t.Error()), // GenericDeleted
498− getColor(t.Text()), // GenericEmph
499− getColor(t.Error()), // GenericError
500− getColor(t.Text()), // GenericHeading
501− getColor(t.Success()), // GenericInserted
502− getColor(t.TextMuted()), // GenericOutput
503− getColor(t.Text()), // GenericPrompt
504− getColor(t.Text()), // GenericStrong
505− getColor(t.Text()), // GenericSubheading
506− getColor(t.Error()), // GenericTraceback
507− getColor(t.Text()), // TextWhitespace
508− )
509−
510− r := strings.NewReader(syntaxThemeXml)
511− style := chroma.MustNewXMLStyle(r)
512−
513− // Modify the style to use the provided background
514− s, err := style.Builder().Transform(
515− func(t chroma.StyleEntry) chroma.StyleEntry {
516− r, g, b, _ := bg.RGBA()
517− t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
518− return t
519− },
520− ).Build()
521− if err != nil {
522− s = styles.Fallback
523− }
524−
525− // Tokenize and format
526− it, err := l.Tokenise(nil, source)
527− if err != nil {
528− return err
529− }
530−
531− return f.Format(w, s, it)
532−}
533−
534−// getColor returns the appropriate hex color string based on terminal background
535−func getColor(adaptiveColor lipgloss.AdaptiveColor) string {
536− if lipgloss.HasDarkBackground() {
537− return adaptiveColor.Dark
538− }
539− return adaptiveColor.Light
317+func getColor(c color.Color) string {
318+ rgba := color.RGBAModel.Convert(c).(color.RGBA)
319+ return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
540320}
541321
542322// highlightLine applies syntax highlighting to a single line
543−func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
544− var buf bytes.Buffer
545− err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
323+func highlightLine(fileName string, line string, bg color.Color) string {
324+ highlighted, err := highlight.SyntaxHighlight(line, fileName, bg)
546325 if err != nil {
547326 return line
548327 }
549− return buf.String()
328+ return highlighted
550329}
551330
552331// createStyles generates the lipgloss styles needed for rendering diffs
553−func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
554− removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
555− addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
556− contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
557− lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
558−
332+func createStyles(t *styles.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
333+ removedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.RemovedBg)
334+ addedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.AddedBg)
335+ contextLineStyle = lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
336+ lineNumberStyle = lipgloss.NewStyle().Foreground(t.S().Diff.LineNumber)
559337 return
560338}
561339
@@ -563,20 +341,8 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
563341// Rendering Functions
564342// -------------------------------------------------------------------------
565343
566−func lipglossToHex(color lipgloss.Color) string {
567− r, g, b, a := color.RGBA()
568−
569− // Scale uint32 values (0-65535) to uint8 (0-255).
570− r8 := uint8(r >> 8)
571− g8 := uint8(g >> 8)
572− b8 := uint8(b >> 8)
573− a8 := uint8(a >> 8)
574−
575− return fmt.Sprintf("#%02x%02x%02x%02x", r8, g8, b8, a8)
576−}
577−
578344// applyHighlighting applies intra-line highlighting to a piece of text
579−func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string {
345+func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string {
580346 // Find all ANSI sequences in the content
581347 ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
582348 ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
@@ -616,7 +382,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
616382
617383 // Get the appropriate color based on terminal background
618384 bgColor := lipgloss.Color(getColor(highlightBg))
619− fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
385+ // fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
620386
621387 for i := 0; i < len(content); {
622388 // Check if we're at an ANSI sequence
@@ -653,15 +419,15 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
653419 currentStyle := ansiSequences[currentPos]
654420
655421 // Apply foreground and background highlight
656− sb.WriteString("\x1b[38;2;")
657− r, g, b, _ := fgColor.RGBA()
658− sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
422+ // sb.WriteString("\x1b[38;2;")
423+ // r, g, b, _ := fgColor.RGBA()
424+ // sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
659425 sb.WriteString("\x1b[48;2;")
660− r, g, b, _ = bgColor.RGBA()
426+ r, g, b, _ := bgColor.RGBA()
661427 sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
662428 sb.WriteString(char)
663429 // Reset foreground and background
664− sb.WriteString("\x1b[39m")
430+ // sb.WriteString("\x1b[39m")
665431
666432 // Reapply the original ANSI sequence
667433 sb.WriteString(currentStyle)
@@ -679,10 +445,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
679445
680446// renderLeftColumn formats the left side of a side-by-side diff
681447func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
682− t := theme.CurrentTheme()
448+ t := styles.CurrentTheme()
683449
684450 if dl == nil {
685− contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
451+ contextLineStyle := t.S().Base.Background(t.S().Diff.ContextBg)
686452 return contextLineStyle.Width(colWidth).Render("")
687453 }
688454
@@ -693,9 +459,9 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
693459 var bgStyle lipgloss.Style
694460 switch dl.Kind {
695461 case LineRemoved:
696− marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
462+ marker = removedLineStyle.Foreground(t.S().Diff.Removed).Render("-")
697463 bgStyle = removedLineStyle
698− lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
464+ lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Removed).Background(t.S().Diff.RemovedLineNumberBg)
699465 case LineAdded:
700466 marker = "?"
701467 bgStyle = contextLineStyle
@@ -718,7 +484,7 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
718484
719485 // Apply intra-line highlighting for removed lines
720486 if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
721− content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
487+ content = applyHighlighting(content, dl.Segments, LineRemoved, t.S().Diff.HighlightRemoved)
722488 }
723489
724490 // Add a padding space for removed lines
@@ -732,17 +498,17 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
732498 ansi.Truncate(
733499 lineText,
734500 colWidth,
735− lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
501+ lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
736502 ),
737503 )
738504}
739505
740506// renderRightColumn formats the right side of a side-by-side diff
741507func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
742− t := theme.CurrentTheme()
508+ t := styles.CurrentTheme()
743509
744510 if dl == nil {
745− contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
511+ contextLineStyle := lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
746512 return contextLineStyle.Width(colWidth).Render("")
747513 }
748514
@@ -753,9 +519,9 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
753519 var bgStyle lipgloss.Style
754520 switch dl.Kind {
755521 case LineAdded:
756− marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
522+ marker = addedLineStyle.Foreground(t.S().Diff.Added).Render("+")
757523 bgStyle = addedLineStyle
758− lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
524+ lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Added).Background(t.S().Diff.AddedLineNumberBg)
759525 case LineRemoved:
760526 marker = "?"
761527 bgStyle = contextLineStyle
@@ -778,7 +544,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
778544
779545 // Apply intra-line highlighting for added lines
780546 if dl.Kind == LineAdded && len(dl.Segments) > 0 {
781− content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
547+ content = applyHighlighting(content, dl.Segments, LineAdded, t.S().Diff.HighlightAdded)
782548 }
783549
784550 // Add a padding space for added lines
@@ -792,7 +558,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
792558 ansi.Truncate(
793559 lineText,
794560 colWidth,
795− lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
561+ lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
796562 ),
797563 )
798564}
internal/fileutil/fileutil.go+82−32
@@ -2,7 +2,6 @@ package fileutil
22
33import (
44 "fmt"
5− "io/fs"
65 "os"
76 "os/exec"
87 "path/filepath"
@@ -11,7 +10,9 @@ import (
1110 "time"
1211
1312 "github.com/bmatcuk/doublestar/v4"
14− "github.com/opencode-ai/opencode/internal/logging"
13+ "github.com/charlievieth/fastwalk"
14+ "github.com/charmbracelet/crush/internal/logging"
15+ ignore "github.com/sabhiram/go-gitignore"
1516)
1617
1718var (
@@ -53,21 +54,6 @@ func GetRgCmd(globPattern string) *exec.Cmd {
5354 return cmd
5455}
5556
56−func GetFzfCmd(query string) *exec.Cmd {
57− if fzfPath == "" {
58− return nil
59− }
60− fzfArgs := []string{
61− "--filter",
62− query,
63− "--read0",
64− "--print0",
65− }
66− cmd := exec.Command(fzfPath, fzfArgs...)
67− cmd.Dir = "."
68− return cmd
69−}
70−
7157type FileInfo struct {
7258 Path string
7359 ModTime time.Time
@@ -81,7 +67,7 @@ func SkipHidden(path string) bool {
8167 }
8268
8369 commonIgnoredDirs := map[string]bool{
84− ".opencode": true,
70+ ".crush": true,
8571 "node_modules": true,
8672 "vendor": true,
8773 "dist": true,
@@ -112,37 +98,92 @@ func SkipHidden(path string) bool {
11298 return false
11399}
114100
115−func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
116− fsys := os.DirFS(searchPath)
117− relPattern := strings.TrimPrefix(pattern, "/")
101+// FastGlobWalker provides gitignore-aware file walking with fastwalk
102+type FastGlobWalker struct {
103+ gitignore *ignore.GitIgnore
104+ rootPath string
105+}
106+
107+func NewFastGlobWalker(searchPath string) *FastGlobWalker {
108+ walker := &FastGlobWalker{
109+ rootPath: searchPath,
110+ }
111+
112+ // Load gitignore if it exists
113+ gitignorePath := filepath.Join(searchPath, ".gitignore")
114+ if _, err := os.Stat(gitignorePath); err == nil {
115+ if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
116+ walker.gitignore = gi
117+ }
118+ }
119+
120+ return walker
121+}
122+
123+func (w *FastGlobWalker) shouldSkip(path string) bool {
124+ if SkipHidden(path) {
125+ return true
126+ }
127+
128+ if w.gitignore != nil {
129+ relPath, err := filepath.Rel(w.rootPath, path)
130+ if err == nil && w.gitignore.MatchesPath(relPath) {
131+ return true
132+ }
133+ }
134+
135+ return false
136+}
137+
138+func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
139+ walker := NewFastGlobWalker(searchPath)
118140 var matches []FileInfo
141+ conf := fastwalk.Config{
142+ Follow: true,
143+ // Use forward slashes when running a Windows binary under WSL or MSYS
144+ ToSlash: fastwalk.DefaultToSlash(),
145+ Sort: fastwalk.SortFilesFirst,
146+ }
147+ err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
148+ if err != nil {
149+ return nil // Skip files we can't access
150+ }
119151
120− err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
121152 if d.IsDir() {
153+ if walker.shouldSkip(path) {
154+ return filepath.SkipDir
155+ }
122156 return nil
123157 }
124− if SkipHidden(path) {
158+
159+ if walker.shouldSkip(path) {
125160 return nil
126161 }
127− info, err := d.Info()
162+
163+ // Check if path matches the pattern
164+ relPath, err := filepath.Rel(searchPath, path)
128165 if err != nil {
166+ relPath = path
167+ }
168+
169+ matched, err := doublestar.Match(pattern, relPath)
170+ if err != nil || !matched {
129171 return nil
130172 }
131− absPath := path
132− if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
133− absPath = filepath.Join(searchPath, absPath)
134− } else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
135− absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
173+
174+ info, err := d.Info()
175+ if err != nil {
176+ return nil
136177 }
137178
138− matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
179+ matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
139180 if limit > 0 && len(matches) >= limit*2 {
140− return fs.SkipAll
181+ return filepath.SkipAll
141182 }
142183 return nil
143184 })
144185 if err != nil {
145− return nil, false, fmt.Errorf("glob walk error: %w", err)
186+ return nil, false, fmt.Errorf("fastwalk error: %w", err)
146187 }
147188
148189 sort.Slice(matches, func(i, j int) bool {
@@ -161,3 +202,12 @@ func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool,
161202 }
162203 return results, truncated, nil
163204}
205+
206+func PrettyPath(path string) string {
207+ // replace home directory with ~
208+ homeDir, err := os.UserHomeDir()
209+ if err == nil {
210+ path = strings.ReplaceAll(path, homeDir, "~")
211+ }
212+ return path
213+}
internal/fileutil/ls.gonew file+169
@@ -0,0 +1,169 @@
1+package fileutil
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+
8+ "github.com/charlievieth/fastwalk"
9+ ignore "github.com/sabhiram/go-gitignore"
10+)
11+
12+// CommonIgnorePatterns contains commonly ignored files and directories
13+var CommonIgnorePatterns = []string{
14+ // Version control
15+ ".git",
16+ ".svn",
17+ ".hg",
18+ ".bzr",
19+
20+ // IDE and editor files
21+ ".vscode",
22+ ".idea",
23+ "*.swp",
24+ "*.swo",
25+ "*~",
26+ ".DS_Store",
27+ "Thumbs.db",
28+
29+ // Build artifacts and dependencies
30+ "node_modules",
31+ "target",
32+ "build",
33+ "dist",
34+ "out",
35+ "bin",
36+ "obj",
37+ "*.o",
38+ "*.so",
39+ "*.dylib",
40+ "*.dll",
41+ "*.exe",
42+
43+ // Logs and temporary files
44+ "*.log",
45+ "*.tmp",
46+ "*.temp",
47+ ".cache",
48+ ".tmp",
49+
50+ // Language-specific
51+ "__pycache__",
52+ "*.pyc",
53+ "*.pyo",
54+ ".pytest_cache",
55+ "vendor",
56+ "Cargo.lock",
57+ "package-lock.json",
58+ "yarn.lock",
59+ "pnpm-lock.yaml",
60+
61+ // OS generated files
62+ ".Trash",
63+ ".Spotlight-V100",
64+ ".fseventsd",
65+
66+ // Crush
67+ ".crush",
68+}
69+
70+type DirectoryLister struct {
71+ gitignore *ignore.GitIgnore
72+ commonIgnore *ignore.GitIgnore
73+ rootPath string
74+}
75+
76+func NewDirectoryLister(rootPath string) *DirectoryLister {
77+ dl := &DirectoryLister{
78+ rootPath: rootPath,
79+ }
80+
81+ // Load gitignore if it exists
82+ gitignorePath := filepath.Join(rootPath, ".gitignore")
83+ if _, err := os.Stat(gitignorePath); err == nil {
84+ if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
85+ dl.gitignore = gi
86+ }
87+ }
88+
89+ // Create common ignore patterns
90+ dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...)
91+
92+ return dl
93+}
94+
95+func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
96+ relPath, err := filepath.Rel(dl.rootPath, path)
97+ if err != nil {
98+ relPath = path
99+ }
100+
101+ // Check common ignore patterns
102+ if dl.commonIgnore.MatchesPath(relPath) {
103+ return true
104+ }
105+
106+ // Check gitignore patterns if available
107+ if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) {
108+ return true
109+ }
110+
111+ base := filepath.Base(path)
112+
113+ if base != "." && strings.HasPrefix(base, ".") {
114+ return true
115+ }
116+
117+ for _, pattern := range ignorePatterns {
118+ matched, err := filepath.Match(pattern, base)
119+ if err == nil && matched {
120+ return true
121+ }
122+ }
123+ return false
124+}
125+
126+// ListDirectory lists files and directories in the specified path,
127+func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
128+ var results []string
129+ truncated := false
130+ dl := NewDirectoryLister(initialPath)
131+
132+ conf := fastwalk.Config{
133+ Follow: true,
134+ // Use forward slashes when running a Windows binary under WSL or MSYS
135+ ToSlash: fastwalk.DefaultToSlash(),
136+ Sort: fastwalk.SortDirsFirst,
137+ }
138+ err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
139+ if err != nil {
140+ return nil // Skip files we don't have permission to access
141+ }
142+
143+ if dl.shouldIgnore(path, ignorePatterns) {
144+ if d.IsDir() {
145+ return filepath.SkipDir
146+ }
147+ return nil
148+ }
149+
150+ if path != initialPath {
151+ if d.IsDir() {
152+ path = path + string(filepath.Separator)
153+ }
154+ results = append(results, path)
155+ }
156+
157+ if limit > 0 && len(results) >= limit {
158+ truncated = true
159+ return filepath.SkipAll
160+ }
161+
162+ return nil
163+ })
164+ if err != nil {
165+ return nil, truncated, err
166+ }
167+
168+ return results, truncated, nil
169+}
internal/format/spinner.go+3−3
@@ -5,8 +5,8 @@ import (
55 "fmt"
66 "os"
77
8− "github.com/charmbracelet/bubbles/spinner"
9− tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/bubbles/v2/spinner"
9+ tea "github.com/charmbracelet/bubbletea/v2"
1010)
1111
1212// Spinner wraps the bubbles spinner for non-interactive mode
@@ -31,7 +31,7 @@ func (m spinnerModel) Init() tea.Cmd {
3131
3232func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3333 switch msg := msg.(type) {
34− case tea.KeyMsg:
34+ case tea.KeyPressMsg:
3535 m.quitting = true
3636 return m, tea.Quit
3737 case spinner.TickMsg:
internal/highlight/highlight.gonew file+60
@@ -0,0 +1,60 @@
1+package highlight
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "image/color"
7+
8+ "github.com/alecthomas/chroma/v2"
9+ "github.com/alecthomas/chroma/v2/formatters"
10+ "github.com/alecthomas/chroma/v2/lexers"
11+ chromaStyles "github.com/alecthomas/chroma/v2/styles"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
13+)
14+
15+func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
16+ // Determine the language lexer to use
17+ l := lexers.Match(fileName)
18+ if l == nil {
19+ l = lexers.Analyse(source)
20+ }
21+ if l == nil {
22+ l = lexers.Fallback
23+ }
24+ l = chroma.Coalesce(l)
25+
26+ // Get the formatter
27+ f := formatters.Get("terminal16m")
28+ if f == nil {
29+ f = formatters.Fallback
30+ }
31+
32+ style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
33+
34+ // Modify the style to use the provided background
35+ s, err := style.Builder().Transform(
36+ func(t chroma.StyleEntry) chroma.StyleEntry {
37+ r, g, b, _ := bg.RGBA()
38+ t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
39+ return t
40+ },
41+ ).Build()
42+ if err != nil {
43+ s = chromaStyles.Fallback
44+ }
45+
46+ // Tokenize and format
47+ it, err := l.Tokenise(nil, source)
48+ if err != nil {
49+ return "", err
50+ }
51+
52+ var buf bytes.Buffer
53+ err = f.Format(&buf, s, it)
54+ return buf.String(), err
55+}
56+
57+func getColor(c color.Color) string {
58+ rgba := color.RGBAModel.Convert(c).(color.RGBA)
59+ return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
60+}
internal/history/file.go+2−2
@@ -8,9 +8,9 @@ import (
88 "strings"
99 "time"
1010
11+ "github.com/charmbracelet/crush/internal/db"
12+ "github.com/charmbracelet/crush/internal/pubsub"
1113 "github.com/google/uuid"
12− "github.com/opencode-ai/opencode/internal/db"
13− "github.com/opencode-ai/opencode/internal/pubsub"
1414)
1515
1616const (
internal/llm/agent/agent-tool.go+5−5
@@ -5,11 +5,11 @@ import (
55 "encoding/json"
66 "fmt"
77
8− "github.com/opencode-ai/opencode/internal/config"
9− "github.com/opencode-ai/opencode/internal/llm/tools"
10− "github.com/opencode-ai/opencode/internal/lsp"
11− "github.com/opencode-ai/opencode/internal/message"
12− "github.com/opencode-ai/opencode/internal/session"
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/llm/tools"
10+ "github.com/charmbracelet/crush/internal/lsp"
11+ "github.com/charmbracelet/crush/internal/message"
12+ "github.com/charmbracelet/crush/internal/session"
1313)
1414
1515type agentTool struct {
internal/llm/agent/agent.go+30−30
@@ -8,16 +8,16 @@ import (
88 "sync"
99 "time"
1010
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/llm/models"
13− "github.com/opencode-ai/opencode/internal/llm/prompt"
14− "github.com/opencode-ai/opencode/internal/llm/provider"
15− "github.com/opencode-ai/opencode/internal/llm/tools"
16− "github.com/opencode-ai/opencode/internal/logging"
17− "github.com/opencode-ai/opencode/internal/message"
18− "github.com/opencode-ai/opencode/internal/permission"
19− "github.com/opencode-ai/opencode/internal/pubsub"
20− "github.com/opencode-ai/opencode/internal/session"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/llm/models"
13+ "github.com/charmbracelet/crush/internal/llm/prompt"
14+ "github.com/charmbracelet/crush/internal/llm/provider"
15+ "github.com/charmbracelet/crush/internal/llm/tools"
16+ "github.com/charmbracelet/crush/internal/logging"
17+ "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/permission"
19+ "github.com/charmbracelet/crush/internal/pubsub"
20+ "github.com/charmbracelet/crush/internal/session"
2121)
2222
2323// Common errors
@@ -443,18 +443,14 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
443443 assistantMsg.AppendContent(event.Content)
444444 return a.messages.Update(ctx, *assistantMsg)
445445 case provider.EventToolUseStart:
446+ logging.Info("Tool call started", "toolCall", event.ToolCall)
446447 assistantMsg.AddToolCall(*event.ToolCall)
447448 return a.messages.Update(ctx, *assistantMsg)
448− // TODO: see how to handle this
449− // case provider.EventToolUseDelta:
450− // tm := time.Unix(assistantMsg.UpdatedAt, 0)
451− // assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
452− // if time.Since(tm) > 1000*time.Millisecond {
453− // err := a.messages.Update(ctx, *assistantMsg)
454− // assistantMsg.UpdatedAt = time.Now().Unix()
455− // return err
456− // }
449+ case provider.EventToolUseDelta:
450+ assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
451+ return a.messages.Update(ctx, *assistantMsg)
457452 case provider.EventToolUseStop:
453+ logging.Info("Finished tool call", "toolCall", event.ToolCall)
458454 assistantMsg.FinishToolCall(event.ToolCall.ID)
459455 return a.messages.Update(ctx, *assistantMsg)
460456 case provider.EventError:
@@ -590,22 +586,26 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
590586 a.Publish(pubsub.CreatedEvent, event)
591587
592588 // Send the messages to the summarize provider
593− response, err := a.summarizeProvider.SendMessages(
589+ response := a.summarizeProvider.StreamResponse(
594590 summarizeCtx,
595591 msgsWithPrompt,
596592 make([]tools.BaseTool, 0),
597593 )
598− if err != nil {
599− event = AgentEvent{
600− Type: AgentEventTypeError,
601− Error: fmt.Errorf("failed to summarize: %w", err),
602− Done: true,
594+ var finalResponse *provider.ProviderResponse
595+ for r := range response {
596+ if r.Error != nil {
597+ event = AgentEvent{
598+ Type: AgentEventTypeError,
599+ Error: fmt.Errorf("failed to summarize: %w", err),
600+ Done: true,
601+ }
602+ a.Publish(pubsub.CreatedEvent, event)
603+ return
603604 }
604− a.Publish(pubsub.CreatedEvent, event)
605− return
605+ finalResponse = r.Response
606606 }
607607
608− summary := strings.TrimSpace(response.Content)
608+ summary := strings.TrimSpace(finalResponse.Content)
609609 if summary == "" {
610610 event = AgentEvent{
611611 Type: AgentEventTypeError,
@@ -655,10 +655,10 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
655655 return
656656 }
657657 oldSession.SummaryMessageID = msg.ID
658− oldSession.CompletionTokens = response.Usage.OutputTokens
658+ oldSession.CompletionTokens = finalResponse.Usage.OutputTokens
659659 oldSession.PromptTokens = 0
660660 model := a.summarizeProvider.Model()
661− usage := response.Usage
661+ usage := finalResponse.Usage
662662 cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
663663 model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
664664 model.CostPer1MIn/1e6*float64(usage.InputTokens) +
internal/llm/agent/mcp-tools.go+7−7
@@ -5,11 +5,11 @@ import (
55 "encoding/json"
66 "fmt"
77
8− "github.com/opencode-ai/opencode/internal/config"
9− "github.com/opencode-ai/opencode/internal/llm/tools"
10− "github.com/opencode-ai/opencode/internal/logging"
11− "github.com/opencode-ai/opencode/internal/permission"
12− "github.com/opencode-ai/opencode/internal/version"
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/llm/tools"
10+ "github.com/charmbracelet/crush/internal/logging"
11+ "github.com/charmbracelet/crush/internal/permission"
12+ "github.com/charmbracelet/crush/internal/version"
1313
1414 "github.com/mark3labs/mcp-go/client"
1515 "github.com/mark3labs/mcp-go/mcp"
@@ -46,7 +46,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
4646 initRequest := mcp.InitializeRequest{}
4747 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
4848 initRequest.Params.ClientInfo = mcp.Implementation{
49− Name: "OpenCode",
49+ Name: "Crush",
5050 Version: version.Version,
5151 }
5252
@@ -140,7 +140,7 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
140140 initRequest := mcp.InitializeRequest{}
141141 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
142142 initRequest.Params.ClientInfo = mcp.Implementation{
143− Name: "OpenCode",
143+ Name: "Crush",
144144 Version: version.Version,
145145 }
146146
internal/llm/agent/tools.go+6−6
@@ -3,12 +3,12 @@ package agent
33import (
44 "context"
55
6− "github.com/opencode-ai/opencode/internal/history"
7− "github.com/opencode-ai/opencode/internal/llm/tools"
8− "github.com/opencode-ai/opencode/internal/lsp"
9− "github.com/opencode-ai/opencode/internal/message"
10− "github.com/opencode-ai/opencode/internal/permission"
11− "github.com/opencode-ai/opencode/internal/session"
6+ "github.com/charmbracelet/crush/internal/history"
7+ "github.com/charmbracelet/crush/internal/llm/tools"
8+ "github.com/charmbracelet/crush/internal/lsp"
9+ "github.com/charmbracelet/crush/internal/message"
10+ "github.com/charmbracelet/crush/internal/permission"
11+ "github.com/charmbracelet/crush/internal/session"
1212)
1313
1414func CoderAgentTools(
internal/llm/models/local.go+5−5
@@ -2,6 +2,7 @@ package models
22
33import (
44 "cmp"
5+ "context"
56 "encoding/json"
67 "net/http"
78 "net/url"
@@ -10,7 +11,7 @@ import (
1011 "strings"
1112 "unicode"
1213
13− "github.com/opencode-ai/opencode/internal/logging"
14+ "github.com/charmbracelet/crush/internal/logging"
1415 "github.com/spf13/viper"
1516)
1617
@@ -53,7 +54,6 @@ func init() {
5354 loadLocalModels(models)
5455
5556 viper.SetDefault("providers.local.apiKey", "dummy")
56− ProviderPopularity[ProviderLocal] = 0
5757 }
5858}
5959
@@ -75,7 +75,7 @@ type localModel struct {
7575}
7676
7777func listLocalModels(modelsEndpoint string) []localModel {
78− res, err := http.Get(modelsEndpoint)
78+ res, err := http.NewRequestWithContext(context.Background(), http.MethodGet, modelsEndpoint, nil)
7979 if err != nil {
8080 logging.Debug("Failed to list local models",
8181 "error", err,
@@ -84,9 +84,9 @@ func listLocalModels(modelsEndpoint string) []localModel {
8484 }
8585 defer res.Body.Close()
8686
87− if res.StatusCode != http.StatusOK {
87+ if res.Response.StatusCode != http.StatusOK {
8888 logging.Debug("Failed to list local models",
89− "status", res.StatusCode,
89+ "status", res.Response.Status,
9090 "endpoint", modelsEndpoint,
9191 )
9292 }
internal/llm/models/models.go+1−37
@@ -34,44 +34,8 @@ const (
3434 ProviderMock ModelProvider = "__mock"
3535)
3636
37−// Providers in order of popularity
38−var ProviderPopularity = map[ModelProvider]int{
39− ProviderAnthropic: 1,
40− ProviderOpenAI: 2,
41− ProviderGemini: 3,
42− ProviderGROQ: 4,
43− ProviderOpenRouter: 5,
44− ProviderBedrock: 6,
45− ProviderAzure: 7,
46− ProviderVertexAI: 8,
47−}
48−
4937var SupportedModels = map[ModelID]Model{
50− //
51− // // GEMINI
52− // GEMINI25: {
53− // ID: GEMINI25,
54− // Name: "Gemini 2.5 Pro",
55− // Provider: ProviderGemini,
56− // APIModel: "gemini-2.5-pro-exp-03-25",
57− // CostPer1MIn: 0,
58− // CostPer1MInCached: 0,
59− // CostPer1MOutCached: 0,
60− // CostPer1MOut: 0,
61− // },
62− //
63− // GRMINI20Flash: {
64− // ID: GRMINI20Flash,
65− // Name: "Gemini 2.0 Flash",
66− // Provider: ProviderGemini,
67− // APIModel: "gemini-2.0-flash",
68− // CostPer1MIn: 0.1,
69− // CostPer1MInCached: 0,
70− // CostPer1MOutCached: 0.025,
71− // CostPer1MOut: 0.4,
72− // },
73− //
74− // // Bedrock
38+ // Bedrock
7539 BedrockClaude37Sonnet: {
7640 ID: BedrockClaude37Sonnet,
7741 Name: "Bedrock: Claude 3.7 Sonnet",
internal/llm/prompt/coder.go+11−11
@@ -8,9 +8,9 @@ import (
88 "runtime"
99 "time"
1010
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/llm/models"
13− "github.com/opencode-ai/opencode/internal/llm/tools"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/llm/models"
13+ "github.com/charmbracelet/crush/internal/llm/tools"
1414)
1515
1616func CoderPrompt(provider models.ModelProvider) string {
@@ -25,7 +25,7 @@ func CoderPrompt(provider models.ModelProvider) string {
2525}
2626
2727const baseOpenAICoderPrompt = `
28−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.
28+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.
2929
3030You can:
3131- Receive user prompts, project context, and files.
@@ -33,7 +33,7 @@ You can:
3333- Apply patches, run commands, and manage user approvals based on policy.
3434- Work inside a sandboxed, git-backed workspace with rollback support.
3535- Log telemetry so sessions can be replayed or inspected later.
36−- More details on your functionality are available at "opencode --help"
36+- More details on your functionality are available at "crush --help"
3737
3838
3939You 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:
7171- Remember the user does not see the full output of tools
7272`
7373
74−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.
74+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.
7575
7676IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
7777
7878# Memory
79−If the current working directory contains a file called OpenCode.md, it will be automatically added to your context. This file serves multiple purposes:
79+If the current working directory contains a file called Crush.md, it will be automatically added to your context. This file serves multiple purposes:
80801. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
81812. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
82823. Maintaining useful information about the codebase structure and organization
8383
84−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.
84+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.
8585
8686# Tone and style
8787You 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
153153
154154# Doing tasks
155155The 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:
156−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.
156+1. Use the available search tools to understand the codebase and the user's query.
1571572. Implement the solution using all tools available to you
1581583. 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.
159−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.
159+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.
160160
161161NEVER 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.
162162
163163# Tool usage policy
164164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
165−- 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.
165+- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
166166- 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.
167167
168168You 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+3−3
@@ -7,9 +7,9 @@ import (
77 "strings"
88 "sync"
99
10− "github.com/opencode-ai/opencode/internal/config"
11− "github.com/opencode-ai/opencode/internal/llm/models"
12− "github.com/opencode-ai/opencode/internal/logging"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/logging"
1313)
1414
1515func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
internal/llm/prompt/prompt_test.go+4−4
@@ -6,7 +6,7 @@ import (
66 "path/filepath"
77 "testing"
88
9− "github.com/opencode-ai/opencode/internal/config"
9+ "github.com/charmbracelet/crush/internal/config"
1010 "github.com/stretchr/testify/assert"
1111 "github.com/stretchr/testify/require"
1212)
@@ -44,13 +44,13 @@ func createTestFiles(t *testing.T, tmpDir string, testFiles []string) {
4444 for _, path := range testFiles {
4545 fullPath := filepath.Join(tmpDir, path)
4646 if path[len(path)-1] == '/' {
47− err := os.MkdirAll(fullPath, 0755)
47+ err := os.MkdirAll(fullPath, 0o755)
4848 require.NoError(t, err)
4949 } else {
5050 dir := filepath.Dir(fullPath)
51− err := os.MkdirAll(dir, 0755)
51+ err := os.MkdirAll(dir, 0o755)
5252 require.NoError(t, err)
53− err = os.WriteFile(fullPath, []byte(path+": test content"), 0644)
53+ err = os.WriteFile(fullPath, []byte(path+": test content"), 0o644)
5454 require.NoError(t, err)
5555 }
5656 }
internal/llm/prompt/summarizer.go+1−1
@@ -1,6 +1,6 @@
11package prompt
22
3−import "github.com/opencode-ai/opencode/internal/llm/models"
3+import "github.com/charmbracelet/crush/internal/llm/models"
44
55func SummarizerPrompt(_ models.ModelProvider) string {
66 return `You are a helpful AI assistant tasked with summarizing conversations.
internal/llm/prompt/task.go+2−2
@@ -3,11 +3,11 @@ package prompt
33import (
44 "fmt"
55
6− "github.com/opencode-ai/opencode/internal/llm/models"
6+ "github.com/charmbracelet/crush/internal/llm/models"
77)
88
99func TaskPrompt(_ models.ModelProvider) string {
10− 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.
10+ 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.
1111Notes:
12121. 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...".
13132. When relevant, share file names and code snippets relevant to the query
internal/llm/prompt/title.go+1−1
@@ -1,6 +1,6 @@
11package prompt
22
3−import "github.com/opencode-ai/opencode/internal/llm/models"
3+import "github.com/charmbracelet/crush/internal/llm/models"
44
55func TitlePrompt(_ models.ModelProvider) string {
66 return `you will generate a short title based on the first message a user begins a conversation with
internal/llm/provider/anthropic.go+8−7
@@ -12,11 +12,11 @@ import (
1212 "github.com/anthropics/anthropic-sdk-go"
1313 "github.com/anthropics/anthropic-sdk-go/bedrock"
1414 "github.com/anthropics/anthropic-sdk-go/option"
15− "github.com/opencode-ai/opencode/internal/config"
16− "github.com/opencode-ai/opencode/internal/llm/models"
17− "github.com/opencode-ai/opencode/internal/llm/tools"
18− "github.com/opencode-ai/opencode/internal/logging"
19− "github.com/opencode-ai/opencode/internal/message"
15+ "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/llm/models"
17+ "github.com/charmbracelet/crush/internal/llm/tools"
18+ "github.com/charmbracelet/crush/internal/logging"
19+ "github.com/charmbracelet/crush/internal/message"
2020)
2121
2222type anthropicOptions struct {
@@ -195,7 +195,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
195195 }
196196}
197197
198−func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) {
198+func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
199199 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
200200 cfg := config.Get()
201201 if cfg.Debug {
@@ -305,7 +305,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
305305 ToolCall: &message.ToolCall{
306306 ID: currentToolCallID,
307307 Finished: false,
308− Input: event.Delta.JSON.PartialJSON.Raw(),
308+ Input: event.Delta.PartialJSON,
309309 },
310310 }
311311 }
@@ -339,6 +339,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
339339 Usage: a.usage(accumulatedMessage),
340340 FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
341341 },
342+ Content: content,
342343 }
343344 }
344345 }
internal/llm/provider/azure.go−1
@@ -16,7 +16,6 @@ type azureClient struct {
1616type AzureClient ProviderClient
1717
1818func newAzureClient(opts providerClientOptions) AzureClient {
19−
2019 endpoint := os.Getenv("AZURE_OPENAI_ENDPOINT") // ex: https://foo.openai.azure.com
2120 apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") // ex: 2025-04-01-preview
2221
internal/llm/provider/bedrock.go+2−3
@@ -7,8 +7,8 @@ import (
77 "os"
88 "strings"
99
10− "github.com/opencode-ai/opencode/internal/llm/tools"
11− "github.com/opencode-ai/opencode/internal/message"
10+ "github.com/charmbracelet/crush/internal/llm/tools"
11+ "github.com/charmbracelet/crush/internal/message"
1212)
1313
1414type bedrockOptions struct {
@@ -98,4 +98,3 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
9898
9999 return b.childProvider.stream(ctx, messages, tools)
100100}
101−
internal/llm/provider/gemini.go+4−6
@@ -9,11 +9,11 @@ import (
99 "strings"
1010 "time"
1111
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/llm/tools"
14+ "github.com/charmbracelet/crush/internal/logging"
15+ "github.com/charmbracelet/crush/internal/message"
1216 "github.com/google/uuid"
13− "github.com/opencode-ai/opencode/internal/config"
14− "github.com/opencode-ai/opencode/internal/llm/tools"
15− "github.com/opencode-ai/opencode/internal/logging"
16− "github.com/opencode-ai/opencode/internal/message"
1717 "google.golang.org/genai"
1818)
1919
@@ -365,7 +365,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
365365 eventChan <- ProviderEvent{Type: EventContentStop}
366366
367367 if finalResp != nil {
368−
369368 finishReason := message.FinishReasonEndTurn
370369 if len(finalResp.Candidates) > 0 {
371370 finishReason = g.finishReason(finalResp.Candidates[0].FinishReason)
@@ -384,7 +383,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
384383 }
385384 return
386385 }
387−
388386 }
389387 }()
390388
internal/llm/provider/openai.go+5−5
@@ -8,14 +8,14 @@ import (
88 "io"
99 "time"
1010
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/llm/models"
13+ "github.com/charmbracelet/crush/internal/llm/tools"
14+ "github.com/charmbracelet/crush/internal/logging"
15+ "github.com/charmbracelet/crush/internal/message"
1116 "github.com/openai/openai-go"
1217 "github.com/openai/openai-go/option"
1318 "github.com/openai/openai-go/shared"
14− "github.com/opencode-ai/opencode/internal/config"
15− "github.com/opencode-ai/opencode/internal/llm/models"
16− "github.com/opencode-ai/opencode/internal/llm/tools"
17− "github.com/opencode-ai/opencode/internal/logging"
18− "github.com/opencode-ai/opencode/internal/message"
1919)
2020
2121type openaiOptions struct {
internal/llm/provider/provider.go+5−5
@@ -5,9 +5,9 @@ import (
55 "fmt"
66 "os"
77
8− "github.com/opencode-ai/opencode/internal/llm/models"
9− "github.com/opencode-ai/opencode/internal/llm/tools"
10− "github.com/opencode-ai/opencode/internal/message"
8+ "github.com/charmbracelet/crush/internal/llm/models"
9+ "github.com/charmbracelet/crush/internal/llm/tools"
10+ "github.com/charmbracelet/crush/internal/message"
1111)
1212
1313type EventType string
@@ -130,8 +130,8 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
130130 clientOptions.openaiOptions = append(clientOptions.openaiOptions,
131131 WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
132132 WithOpenAIExtraHeaders(map[string]string{
133− "HTTP-Referer": "opencode.ai",
134− "X-Title": "OpenCode",
133+ "HTTP-Referer": "crush.ai",
134+ "X-Title": "Crush",
135135 }),
136136 )
137137 return &baseProvider[OpenAIClient]{
internal/llm/provider/vertexai.go+1−1
@@ -4,7 +4,7 @@ import (
44 "context"
55 "os"
66
7− "github.com/opencode-ai/opencode/internal/logging"
7+ "github.com/charmbracelet/crush/internal/logging"
88 "google.golang.org/genai"
99)
1010
internal/llm/tools/bash.go+8−8
@@ -7,9 +7,9 @@ import (
77 "strings"
88 "time"
99
10− "github.com/opencode-ai/opencode/internal/config"
11− "github.com/opencode-ai/opencode/internal/llm/tools/shell"
12− "github.com/opencode-ai/opencode/internal/permission"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/llm/tools/shell"
12+ "github.com/charmbracelet/crush/internal/permission"
1313)
1414
1515type BashParams struct {
@@ -122,16 +122,16 @@ When the user asks you to create a new git commit, follow these steps carefully:
122122</commit_analysis>
123123
1241244. Create the commit with a message ending with:
125−🤖 Generated with opencode
126−Co-Authored-By: opencode <noreply@opencode.ai>
125+🤖 Generated with crush
126+Co-Authored-By: crush <noreply@crush.ai>
127127
128128- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
129129<example>
130130git commit -m "$(cat <<'EOF'
131131 Commit message here.
132132
133− 🤖 Generated with opencode
134− Co-Authored-By: opencode <noreply@opencode.ai>
133+ 🤖 Generated with crush
134+ Co-Authored-By: crush <noreply@crush.ai>
135135 EOF
136136 )"
137137</example>
@@ -193,7 +193,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
193193## Test plan
194194[Checklist of TODOs for testing the pull request...]
195195
196−🤖 Generated with opencode
196+🤖 Generated with crush
197197EOF
198198)"
199199</example>
internal/llm/tools/diagnostics.go+2−2
@@ -9,8 +9,8 @@ import (
99 "strings"
1010 "time"
1111
12− "github.com/opencode-ai/opencode/internal/lsp"
13− "github.com/opencode-ai/opencode/internal/lsp/protocol"
12+ "github.com/charmbracelet/crush/internal/lsp"
13+ "github.com/charmbracelet/crush/internal/lsp/protocol"
1414)
1515
1616type DiagnosticsParams struct {
internal/llm/tools/edit.go+6−6
@@ -9,12 +9,12 @@ import (
99 "strings"
1010 "time"
1111
12− "github.com/opencode-ai/opencode/internal/config"
13− "github.com/opencode-ai/opencode/internal/diff"
14− "github.com/opencode-ai/opencode/internal/history"
15− "github.com/opencode-ai/opencode/internal/logging"
16− "github.com/opencode-ai/opencode/internal/lsp"
17− "github.com/opencode-ai/opencode/internal/permission"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/diff"
14+ "github.com/charmbracelet/crush/internal/history"
15+ "github.com/charmbracelet/crush/internal/logging"
16+ "github.com/charmbracelet/crush/internal/lsp"
17+ "github.com/charmbracelet/crush/internal/permission"
1818)
1919
2020type EditParams struct {
internal/llm/tools/fetch.go+3−3
@@ -11,8 +11,8 @@ import (
1111
1212 md "github.com/JohannesKaufmann/html-to-markdown"
1313 "github.com/PuerkitoBio/goquery"
14− "github.com/opencode-ai/opencode/internal/config"
15− "github.com/opencode-ai/opencode/internal/permission"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/permission"
1616)
1717
1818type FetchParams struct {
@@ -152,7 +152,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
152152 return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
153153 }
154154
155− req.Header.Set("User-Agent", "opencode/1.0")
155+ req.Header.Set("User-Agent", "crush/1.0")
156156
157157 resp, err := client.Do(req)
158158 if err != nil {
internal/llm/tools/glob.go+4−4
@@ -10,9 +10,9 @@ import (
1010 "sort"
1111 "strings"
1212
13− "github.com/opencode-ai/opencode/internal/config"
14− "github.com/opencode-ai/opencode/internal/fileutil"
15− "github.com/opencode-ai/opencode/internal/logging"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/fileutil"
15+ "github.com/charmbracelet/crush/internal/logging"
1616)
1717
1818const (
@@ -137,7 +137,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
137137 logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
138138 }
139139
140− return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
140+ return fileutil.GlobWithDoubleStar(pattern, searchPath, limit)
141141}
142142
143143func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
internal/llm/tools/grep.go+2−2
@@ -14,8 +14,8 @@ import (
1414 "strings"
1515 "time"
1616
17− "github.com/opencode-ai/opencode/internal/config"
18− "github.com/opencode-ai/opencode/internal/fileutil"
17+ "github.com/charmbracelet/crush/internal/config"
18+ "github.com/charmbracelet/crush/internal/fileutil"
1919)
2020
2121type GrepParams struct {
internal/llm/tools/ls.go+3−97
@@ -8,7 +8,8 @@ import (
88 "path/filepath"
99 "strings"
1010
11− "github.com/opencode-ai/opencode/internal/config"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/fileutil"
1213)
1314
1415type LSParams struct {
@@ -107,7 +108,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
107108 return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
108109 }
109110
110− files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
111+ files, truncated, err := fileutil.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
111112 if err != nil {
112113 return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
113114 }
@@ -128,101 +129,6 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
128129 ), nil
129130}
130131
131−func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
132− var results []string
133− truncated := false
134−
135− err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
136− if err != nil {
137− return nil // Skip files we don't have permission to access
138− }
139−
140− if shouldSkip(path, ignorePatterns) {
141− if info.IsDir() {
142− return filepath.SkipDir
143− }
144− return nil
145− }
146−
147− if path != initialPath {
148− if info.IsDir() {
149− path = path + string(filepath.Separator)
150− }
151− results = append(results, path)
152− }
153−
154− if len(results) >= limit {
155− truncated = true
156− return filepath.SkipAll
157− }
158−
159− return nil
160− })
161− if err != nil {
162− return nil, truncated, err
163− }
164−
165− return results, truncated, nil
166−}
167−
168−func shouldSkip(path string, ignorePatterns []string) bool {
169− base := filepath.Base(path)
170−
171− if base != "." && strings.HasPrefix(base, ".") {
172− return true
173− }
174−
175− commonIgnored := []string{
176− "__pycache__",
177− "node_modules",
178− "dist",
179− "build",
180− "target",
181− "vendor",
182− "bin",
183− "obj",
184− ".git",
185− ".idea",
186− ".vscode",
187− ".DS_Store",
188− "*.pyc",
189− "*.pyo",
190− "*.pyd",
191− "*.so",
192− "*.dll",
193− "*.exe",
194− }
195−
196− if strings.Contains(path, filepath.Join("__pycache__", "")) {
197− return true
198− }
199−
200− for _, ignored := range commonIgnored {
201− if strings.HasSuffix(ignored, "/") {
202− if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
203− return true
204− }
205− } else if strings.HasPrefix(ignored, "*.") {
206− if strings.HasSuffix(base, ignored[1:]) {
207− return true
208− }
209− } else {
210− if base == ignored {
211− return true
212− }
213− }
214− }
215−
216− for _, pattern := range ignorePatterns {
217− matched, err := filepath.Match(pattern, base)
218− if err == nil && matched {
219− return true
220− }
221− }
222−
223− return false
224−}
225−
226132func createFileTree(sortedPaths []string) []*TreeNode {
227133 root := []*TreeNode{}
228134 pathMap := make(map[string]*TreeNode)
internal/llm/tools/ls_test.godeleted−457
@@ -1,457 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6− "os"
7− "path/filepath"
8− "strings"
9− "testing"
10−
11− "github.com/stretchr/testify/assert"
12− "github.com/stretchr/testify/require"
13−)
14−
15−func TestLsTool_Info(t *testing.T) {
16− tool := NewLsTool()
17− info := tool.Info()
18−
19− assert.Equal(t, LSToolName, info.Name)
20− assert.NotEmpty(t, info.Description)
21− assert.Contains(t, info.Parameters, "path")
22− assert.Contains(t, info.Parameters, "ignore")
23− assert.Contains(t, info.Required, "path")
24−}
25−
26−func TestLsTool_Run(t *testing.T) {
27− // Create a temporary directory for testing
28− tempDir, err := os.MkdirTemp("", "ls_tool_test")
29− require.NoError(t, err)
30− defer os.RemoveAll(tempDir)
31−
32− // Create a test directory structure
33− testDirs := []string{
34− "dir1",
35− "dir2",
36− "dir2/subdir1",
37− "dir2/subdir2",
38− "dir3",
39− "dir3/.hidden_dir",
40− "__pycache__",
41− }
42−
43− testFiles := []string{
44− "file1.txt",
45− "file2.txt",
46− "dir1/file3.txt",
47− "dir2/file4.txt",
48− "dir2/subdir1/file5.txt",
49− "dir2/subdir2/file6.txt",
50− "dir3/file7.txt",
51− "dir3/.hidden_file.txt",
52− "__pycache__/cache.pyc",
53− ".hidden_root_file.txt",
54− }
55−
56− // Create directories
57− for _, dir := range testDirs {
58− dirPath := filepath.Join(tempDir, dir)
59− err := os.MkdirAll(dirPath, 0755)
60− require.NoError(t, err)
61− }
62−
63− // Create files
64− for _, file := range testFiles {
65− filePath := filepath.Join(tempDir, file)
66− err := os.WriteFile(filePath, []byte("test content"), 0644)
67− require.NoError(t, err)
68− }
69−
70− t.Run("lists directory successfully", func(t *testing.T) {
71− tool := NewLsTool()
72− params := LSParams{
73− Path: tempDir,
74− }
75−
76− paramsJSON, err := json.Marshal(params)
77− require.NoError(t, err)
78−
79− call := ToolCall{
80− Name: LSToolName,
81− Input: string(paramsJSON),
82− }
83−
84− response, err := tool.Run(context.Background(), call)
85− require.NoError(t, err)
86−
87− // Check that visible directories and files are included
88− assert.Contains(t, response.Content, "dir1")
89− assert.Contains(t, response.Content, "dir2")
90− assert.Contains(t, response.Content, "dir3")
91− assert.Contains(t, response.Content, "file1.txt")
92− assert.Contains(t, response.Content, "file2.txt")
93−
94− // Check that hidden files and directories are not included
95− assert.NotContains(t, response.Content, ".hidden_dir")
96− assert.NotContains(t, response.Content, ".hidden_file.txt")
97− assert.NotContains(t, response.Content, ".hidden_root_file.txt")
98−
99− // Check that __pycache__ is not included
100− assert.NotContains(t, response.Content, "__pycache__")
101− })
102−
103− t.Run("handles non-existent path", func(t *testing.T) {
104− tool := NewLsTool()
105− params := LSParams{
106− Path: filepath.Join(tempDir, "non_existent_dir"),
107− }
108−
109− paramsJSON, err := json.Marshal(params)
110− require.NoError(t, err)
111−
112− call := ToolCall{
113− Name: LSToolName,
114− Input: string(paramsJSON),
115− }
116−
117− response, err := tool.Run(context.Background(), call)
118− require.NoError(t, err)
119− assert.Contains(t, response.Content, "path does not exist")
120− })
121−
122− t.Run("handles empty path parameter", func(t *testing.T) {
123− // For this test, we need to mock the config.WorkingDirectory function
124− // Since we can't easily do that, we'll just check that the response doesn't contain an error message
125−
126− tool := NewLsTool()
127− params := LSParams{
128− Path: "",
129− }
130−
131− paramsJSON, err := json.Marshal(params)
132− require.NoError(t, err)
133−
134− call := ToolCall{
135− Name: LSToolName,
136− Input: string(paramsJSON),
137− }
138−
139− response, err := tool.Run(context.Background(), call)
140− require.NoError(t, err)
141−
142− // The response should either contain a valid directory listing or an error
143− // We'll just check that it's not empty
144− assert.NotEmpty(t, response.Content)
145− })
146−
147− t.Run("handles invalid parameters", func(t *testing.T) {
148− tool := NewLsTool()
149− call := ToolCall{
150− Name: LSToolName,
151− Input: "invalid json",
152− }
153−
154− response, err := tool.Run(context.Background(), call)
155− require.NoError(t, err)
156− assert.Contains(t, response.Content, "error parsing parameters")
157− })
158−
159− t.Run("respects ignore patterns", func(t *testing.T) {
160− tool := NewLsTool()
161− params := LSParams{
162− Path: tempDir,
163− Ignore: []string{"file1.txt", "dir1"},
164− }
165−
166− paramsJSON, err := json.Marshal(params)
167− require.NoError(t, err)
168−
169− call := ToolCall{
170− Name: LSToolName,
171− Input: string(paramsJSON),
172− }
173−
174− response, err := tool.Run(context.Background(), call)
175− require.NoError(t, err)
176−
177− // The output format is a tree, so we need to check for specific patterns
178− // Check that file1.txt is not directly mentioned
179− assert.NotContains(t, response.Content, "- file1.txt")
180−
181− // Check that dir1/ is not directly mentioned
182− assert.NotContains(t, response.Content, "- dir1/")
183− })
184−
185− t.Run("handles relative path", func(t *testing.T) {
186− // Save original working directory
187− origWd, err := os.Getwd()
188− require.NoError(t, err)
189− defer func() {
190− os.Chdir(origWd)
191− }()
192−
193− // Change to a directory above the temp directory
194− parentDir := filepath.Dir(tempDir)
195− err = os.Chdir(parentDir)
196− require.NoError(t, err)
197−
198− tool := NewLsTool()
199− params := LSParams{
200− Path: filepath.Base(tempDir),
201− }
202−
203− paramsJSON, err := json.Marshal(params)
204− require.NoError(t, err)
205−
206− call := ToolCall{
207− Name: LSToolName,
208− Input: string(paramsJSON),
209− }
210−
211− response, err := tool.Run(context.Background(), call)
212− require.NoError(t, err)
213−
214− // Should list the temp directory contents
215− assert.Contains(t, response.Content, "dir1")
216− assert.Contains(t, response.Content, "file1.txt")
217− })
218−}
219−
220−func TestShouldSkip(t *testing.T) {
221− testCases := []struct {
222− name string
223− path string
224− ignorePatterns []string
225− expected bool
226− }{
227− {
228− name: "hidden file",
229− path: "/path/to/.hidden_file",
230− ignorePatterns: []string{},
231− expected: true,
232− },
233− {
234− name: "hidden directory",
235− path: "/path/to/.hidden_dir",
236− ignorePatterns: []string{},
237− expected: true,
238− },
239− {
240− name: "pycache directory",
241− path: "/path/to/__pycache__/file.pyc",
242− ignorePatterns: []string{},
243− expected: true,
244− },
245− {
246− name: "node_modules directory",
247− path: "/path/to/node_modules/package",
248− ignorePatterns: []string{},
249− expected: false, // The shouldSkip function doesn't directly check for node_modules in the path
250− },
251− {
252− name: "normal file",
253− path: "/path/to/normal_file.txt",
254− ignorePatterns: []string{},
255− expected: false,
256− },
257− {
258− name: "normal directory",
259− path: "/path/to/normal_dir",
260− ignorePatterns: []string{},
261− expected: false,
262− },
263− {
264− name: "ignored by pattern",
265− path: "/path/to/ignore_me.txt",
266− ignorePatterns: []string{"ignore_*.txt"},
267− expected: true,
268− },
269− {
270− name: "not ignored by pattern",
271− path: "/path/to/keep_me.txt",
272− ignorePatterns: []string{"ignore_*.txt"},
273− expected: false,
274− },
275− }
276−
277− for _, tc := range testCases {
278− t.Run(tc.name, func(t *testing.T) {
279− result := shouldSkip(tc.path, tc.ignorePatterns)
280− assert.Equal(t, tc.expected, result)
281− })
282− }
283−}
284−
285−func TestCreateFileTree(t *testing.T) {
286− paths := []string{
287− "/path/to/file1.txt",
288− "/path/to/dir1/file2.txt",
289− "/path/to/dir1/subdir/file3.txt",
290− "/path/to/dir2/file4.txt",
291− }
292−
293− tree := createFileTree(paths)
294−
295− // Check the structure of the tree
296− assert.Len(t, tree, 1) // Should have one root node
297−
298− // Check the root node
299− rootNode := tree[0]
300− assert.Equal(t, "path", rootNode.Name)
301− assert.Equal(t, "directory", rootNode.Type)
302− assert.Len(t, rootNode.Children, 1)
303−
304− // Check the "to" node
305− toNode := rootNode.Children[0]
306− assert.Equal(t, "to", toNode.Name)
307− assert.Equal(t, "directory", toNode.Type)
308− assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2
309−
310− // Find the dir1 node
311− var dir1Node *TreeNode
312− for _, child := range toNode.Children {
313− if child.Name == "dir1" {
314− dir1Node = child
315− break
316− }
317− }
318−
319− require.NotNil(t, dir1Node)
320− assert.Equal(t, "directory", dir1Node.Type)
321− assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir
322−}
323−
324−func TestPrintTree(t *testing.T) {
325− // Create a simple tree
326− tree := []*TreeNode{
327− {
328− Name: "dir1",
329− Path: "dir1",
330− Type: "directory",
331− Children: []*TreeNode{
332− {
333− Name: "file1.txt",
334− Path: "dir1/file1.txt",
335− Type: "file",
336− },
337− {
338− Name: "subdir",
339− Path: "dir1/subdir",
340− Type: "directory",
341− Children: []*TreeNode{
342− {
343− Name: "file2.txt",
344− Path: "dir1/subdir/file2.txt",
345− Type: "file",
346− },
347− },
348− },
349− },
350− },
351− {
352− Name: "file3.txt",
353− Path: "file3.txt",
354− Type: "file",
355− },
356− }
357−
358− result := printTree(tree, "/root")
359−
360− // Check the output format
361− assert.Contains(t, result, "- /root/")
362− assert.Contains(t, result, " - dir1/")
363− assert.Contains(t, result, " - file1.txt")
364− assert.Contains(t, result, " - subdir/")
365− assert.Contains(t, result, " - file2.txt")
366− assert.Contains(t, result, " - file3.txt")
367−}
368−
369−func TestListDirectory(t *testing.T) {
370− // Create a temporary directory for testing
371− tempDir, err := os.MkdirTemp("", "list_directory_test")
372− require.NoError(t, err)
373− defer os.RemoveAll(tempDir)
374−
375− // Create a test directory structure
376− testDirs := []string{
377− "dir1",
378− "dir1/subdir1",
379− ".hidden_dir",
380− }
381−
382− testFiles := []string{
383− "file1.txt",
384− "file2.txt",
385− "dir1/file3.txt",
386− "dir1/subdir1/file4.txt",
387− ".hidden_file.txt",
388− }
389−
390− // Create directories
391− for _, dir := range testDirs {
392− dirPath := filepath.Join(tempDir, dir)
393− err := os.MkdirAll(dirPath, 0755)
394− require.NoError(t, err)
395− }
396−
397− // Create files
398− for _, file := range testFiles {
399− filePath := filepath.Join(tempDir, file)
400− err := os.WriteFile(filePath, []byte("test content"), 0644)
401− require.NoError(t, err)
402− }
403−
404− t.Run("lists files with no limit", func(t *testing.T) {
405− files, truncated, err := listDirectory(tempDir, []string{}, 1000)
406− require.NoError(t, err)
407− assert.False(t, truncated)
408−
409− // Check that visible files and directories are included
410− containsPath := func(paths []string, target string) bool {
411− targetPath := filepath.Join(tempDir, target)
412− for _, path := range paths {
413− if strings.HasPrefix(path, targetPath) {
414− return true
415− }
416− }
417− return false
418− }
419−
420− assert.True(t, containsPath(files, "dir1"))
421− assert.True(t, containsPath(files, "file1.txt"))
422− assert.True(t, containsPath(files, "file2.txt"))
423− assert.True(t, containsPath(files, "dir1/file3.txt"))
424−
425− // Check that hidden files and directories are not included
426− assert.False(t, containsPath(files, ".hidden_dir"))
427− assert.False(t, containsPath(files, ".hidden_file.txt"))
428− })
429−
430− t.Run("respects limit and returns truncated flag", func(t *testing.T) {
431− files, truncated, err := listDirectory(tempDir, []string{}, 2)
432− require.NoError(t, err)
433− assert.True(t, truncated)
434− assert.Len(t, files, 2)
435− })
436−
437− t.Run("respects ignore patterns", func(t *testing.T) {
438− files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000)
439− require.NoError(t, err)
440− assert.False(t, truncated)
441−
442− // Check that no .txt files are included
443− for _, file := range files {
444− assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file)
445− }
446−
447− // But directories should still be included
448− containsDir := false
449− for _, file := range files {
450− if strings.Contains(file, "dir1") {
451− containsDir = true
452− break
453− }
454− }
455− assert.True(t, containsDir)
456− })
457−}
internal/llm/tools/patch.go+6−6
@@ -8,12 +8,12 @@ import (
88 "path/filepath"
99 "time"
1010
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/diff"
13− "github.com/opencode-ai/opencode/internal/history"
14− "github.com/opencode-ai/opencode/internal/logging"
15− "github.com/opencode-ai/opencode/internal/lsp"
16− "github.com/opencode-ai/opencode/internal/permission"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/diff"
13+ "github.com/charmbracelet/crush/internal/history"
14+ "github.com/charmbracelet/crush/internal/logging"
15+ "github.com/charmbracelet/crush/internal/lsp"
16+ "github.com/charmbracelet/crush/internal/permission"
1717)
1818
1919type PatchParams struct {
internal/llm/tools/shell/shell.go+9−9
@@ -12,7 +12,7 @@ import (
1212 "syscall"
1313 "time"
1414
15− "github.com/opencode-ai/opencode/internal/config"
15+ "github.com/charmbracelet/crush/internal/config"
1616)
1717
1818type PersistentShell struct {
@@ -61,23 +61,23 @@ func GetPersistentShell(workingDir string) *PersistentShell {
6161func newPersistentShell(cwd string) *PersistentShell {
6262 // Get shell configuration from config
6363 cfg := config.Get()
64−
64+
6565 // Default to environment variable if config is not set or nil
6666 var shellPath string
6767 var shellArgs []string
68−
68+
6969 if cfg != nil {
7070 shellPath = cfg.Shell.Path
7171 shellArgs = cfg.Shell.Args
7272 }
73−
73+
7474 if shellPath == "" {
7575 shellPath = os.Getenv("SHELL")
7676 if shellPath == "" {
7777 shellPath = "/bin/bash"
7878 }
7979 }
80−
80+
8181 // Default shell args
8282 if len(shellArgs) == 0 {
8383 shellArgs = []string{"-l"}
@@ -149,10 +149,10 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
149149 }
150150
151151 tempDir := os.TempDir()
152− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano()))
153− stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano()))
154− statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano()))
155− cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano()))
152+ stdoutFile := filepath.Join(tempDir, fmt.Sprintf("crush-stdout-%d", time.Now().UnixNano()))
153+ stderrFile := filepath.Join(tempDir, fmt.Sprintf("crush-stderr-%d", time.Now().UnixNano()))
154+ statusFile := filepath.Join(tempDir, fmt.Sprintf("crush-status-%d", time.Now().UnixNano()))
155+ cwdFile := filepath.Join(tempDir, fmt.Sprintf("crush-cwd-%d", time.Now().UnixNano()))
156156
157157 defer func() {
158158 os.Remove(stdoutFile)
internal/llm/tools/sourcegraph.go+1−1
@@ -218,7 +218,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
218218 }
219219
220220 req.Header.Set("Content-Type", "application/json")
221− req.Header.Set("User-Agent", "opencode/1.0")
221+ req.Header.Set("User-Agent", "crush/1.0")
222222
223223 resp, err := client.Do(req)
224224 if err != nil {
internal/llm/tools/view.go+2−2
@@ -10,8 +10,8 @@ import (
1010 "path/filepath"
1111 "strings"
1212
13− "github.com/opencode-ai/opencode/internal/config"
14− "github.com/opencode-ai/opencode/internal/lsp"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/lsp"
1515)
1616
1717type ViewParams struct {
internal/llm/tools/write.go+6−6
@@ -9,12 +9,12 @@ import (
99 "strings"
1010 "time"
1111
12− "github.com/opencode-ai/opencode/internal/config"
13− "github.com/opencode-ai/opencode/internal/diff"
14− "github.com/opencode-ai/opencode/internal/history"
15− "github.com/opencode-ai/opencode/internal/logging"
16− "github.com/opencode-ai/opencode/internal/lsp"
17− "github.com/opencode-ai/opencode/internal/permission"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/diff"
14+ "github.com/charmbracelet/crush/internal/history"
15+ "github.com/charmbracelet/crush/internal/logging"
16+ "github.com/charmbracelet/crush/internal/lsp"
17+ "github.com/charmbracelet/crush/internal/permission"
1818)
1919
2020type WriteParams struct {
internal/logging/logger.go+1−1
@@ -54,7 +54,7 @@ func RecoverPanic(name string, cleanup func()) {
5454
5555 // Create a timestamped panic log file
5656 timestamp := time.Now().Format("20060102-150405")
57− filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
57+ filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp)
5858
5959 file, err := os.Create(filename)
6060 if err != nil {
internal/logging/writer.go+1−1
@@ -8,8 +8,8 @@ import (
88 "sync"
99 "time"
1010
11+ "github.com/charmbracelet/crush/internal/pubsub"
1112 "github.com/go-logfmt/logfmt"
12− "github.com/opencode-ai/opencode/internal/pubsub"
1313)
1414
1515const (
internal/lsp/client.go+3−3
@@ -14,9 +14,9 @@ import (
1414 "sync/atomic"
1515 "time"
1616
17− "github.com/opencode-ai/opencode/internal/config"
18− "github.com/opencode-ai/opencode/internal/logging"
19− "github.com/opencode-ai/opencode/internal/lsp/protocol"
17+ "github.com/charmbracelet/crush/internal/config"
18+ "github.com/charmbracelet/crush/internal/logging"
19+ "github.com/charmbracelet/crush/internal/lsp/protocol"
2020)
2121
2222type Client struct {
internal/lsp/handlers.go+4−4
@@ -3,10 +3,10 @@ package lsp
33import (
44 "encoding/json"
55
6− "github.com/opencode-ai/opencode/internal/config"
7− "github.com/opencode-ai/opencode/internal/logging"
8− "github.com/opencode-ai/opencode/internal/lsp/protocol"
9− "github.com/opencode-ai/opencode/internal/lsp/util"
6+ "github.com/charmbracelet/crush/internal/config"
7+ "github.com/charmbracelet/crush/internal/logging"
8+ "github.com/charmbracelet/crush/internal/lsp/protocol"
9+ "github.com/charmbracelet/crush/internal/lsp/util"
1010)
1111
1212// Requests
internal/lsp/language.go+1−1
@@ -4,7 +4,7 @@ import (
44 "path/filepath"
55 "strings"
66
7− "github.com/opencode-ai/opencode/internal/lsp/protocol"
7+ "github.com/charmbracelet/crush/internal/lsp/protocol"
88)
99
1010func DetectLanguageID(uri string) protocol.LanguageKind {
internal/lsp/methods.go+1−1
@@ -4,7 +4,7 @@ package lsp
44import (
55 "context"
66
7− "github.com/opencode-ai/opencode/internal/lsp/protocol"
7+ "github.com/charmbracelet/crush/internal/lsp/protocol"
88)
99
1010// Implementation sends a textDocument/implementation request to the LSP server.
internal/lsp/transport.go+2−2
@@ -8,8 +8,8 @@ import (
88 "io"
99 "strings"
1010
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/logging"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/logging"
1313)
1414
1515// Write writes an LSP message to the given writer
internal/lsp/util/edit.go+1−1
@@ -7,7 +7,7 @@ import (
77 "sort"
88 "strings"
99
10− "github.com/opencode-ai/opencode/internal/lsp/protocol"
10+ "github.com/charmbracelet/crush/internal/lsp/protocol"
1111)
1212
1313func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
internal/lsp/watcher/watcher.go+4−5
@@ -10,11 +10,11 @@ import (
1010 "time"
1111
1212 "github.com/bmatcuk/doublestar/v4"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/logging"
15+ "github.com/charmbracelet/crush/internal/lsp"
16+ "github.com/charmbracelet/crush/internal/lsp/protocol"
1317 "github.com/fsnotify/fsnotify"
14− "github.com/opencode-ai/opencode/internal/config"
15− "github.com/opencode-ai/opencode/internal/logging"
16− "github.com/opencode-ai/opencode/internal/lsp"
17− "github.com/opencode-ai/opencode/internal/lsp/protocol"
1818)
1919
2020// WorkspaceWatcher manages LSP file watching
@@ -401,7 +401,6 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
401401 "watched", matched,
402402 "kind", kind,
403403 )
404−
405404 }
406405
407406 // Check if this path should be watched according to server registrations
internal/message/content.go+1−1
@@ -5,7 +5,7 @@ import (
55 "slices"
66 "time"
77
8− "github.com/opencode-ai/opencode/internal/llm/models"
8+ "github.com/charmbracelet/crush/internal/llm/models"
99)
1010
1111type MessageRole string
internal/message/message.go+3−4
@@ -7,10 +7,10 @@ import (
77 "fmt"
88 "time"
99
10+ "github.com/charmbracelet/crush/internal/db"
11+ "github.com/charmbracelet/crush/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/pubsub"
1013 "github.com/google/uuid"
11− "github.com/opencode-ai/opencode/internal/db"
12− "github.com/opencode-ai/opencode/internal/llm/models"
13− "github.com/opencode-ai/opencode/internal/pubsub"
1414)
1515
1616type CreateMessageParams struct {
@@ -274,7 +274,6 @@ func unmarshallParts(data []byte) ([]ContentPart, error) {
274274 default:
275275 return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
276276 }
277−
278277 }
279278
280279 return parts, nil
internal/permission/permission.go+4−4
@@ -6,9 +6,9 @@ import (
66 "slices"
77 "sync"
88
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/pubsub"
911 "github.com/google/uuid"
10− "github.com/opencode-ai/opencode/internal/config"
11− "github.com/opencode-ai/opencode/internal/pubsub"
1212)
1313
1414var ErrorPermissionDenied = errors.New("permission denied")
@@ -34,7 +34,7 @@ type PermissionRequest struct {
3434
3535type Service interface {
3636 pubsub.Suscriber[PermissionRequest]
37− GrantPersistant(permission PermissionRequest)
37+ GrantPersistent(permission PermissionRequest)
3838 Grant(permission PermissionRequest)
3939 Deny(permission PermissionRequest)
4040 Request(opts CreatePermissionRequest) bool
@@ -49,7 +49,7 @@ type permissionService struct {
4949 autoApproveSessions []string
5050}
5151
52−func (s *permissionService) GrantPersistant(permission PermissionRequest) {
52+func (s *permissionService) GrantPersistent(permission PermissionRequest) {
5353 respCh, ok := s.pendingRequests.Load(permission.ID)
5454 if ok {
5555 respCh.(chan bool) <- true
internal/session/session.go+2−2
@@ -4,9 +4,9 @@ import (
44 "context"
55 "database/sql"
66
7+ "github.com/charmbracelet/crush/internal/db"
8+ "github.com/charmbracelet/crush/internal/pubsub"
79 "github.com/google/uuid"
8− "github.com/opencode-ai/opencode/internal/db"
9− "github.com/opencode-ai/opencode/internal/pubsub"
1010)
1111
1212type Session struct {
internal/tui/components/anim/anim.gonew file+307
@@ -0,0 +1,307 @@
1+package anim
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "math/rand/v2"
7+ "strings"
8+ "time"
9+
10+ "github.com/charmbracelet/bubbles/v2/spinner"
11+ tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
13+ "github.com/charmbracelet/crush/internal/tui/util"
14+ "github.com/charmbracelet/lipgloss/v2"
15+ "github.com/google/uuid"
16+ "github.com/lucasb-eyer/go-colorful"
17+)
18+
19+const (
20+ charCyclingFPS = time.Second / 22
21+ colorCycleFPS = time.Second / 5
22+ maxCyclingChars = 120
23+)
24+
25+var charRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
26+
27+type charState int
28+
29+const (
30+ charInitialState charState = iota
31+ charCyclingState
32+ charEndOfLifeState
33+)
34+
35+// cyclingChar is a single animated character.
36+type cyclingChar struct {
37+ finalValue rune // if < 0 cycle forever
38+ currentValue rune
39+ initialDelay time.Duration
40+ lifetime time.Duration
41+}
42+
43+func (c cyclingChar) randomRune() rune {
44+ return (charRunes)[rand.IntN(len(charRunes))] //nolint:gosec
45+}
46+
47+func (c cyclingChar) state(start time.Time) charState {
48+ now := time.Now()
49+ if now.Before(start.Add(c.initialDelay)) {
50+ return charInitialState
51+ }
52+ if c.finalValue > 0 && now.After(start.Add(c.initialDelay)) {
53+ return charEndOfLifeState
54+ }
55+ return charCyclingState
56+}
57+
58+type StepCharsMsg struct {
59+ id string
60+}
61+
62+func stepChars(id string) tea.Cmd {
63+ return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
64+ return StepCharsMsg{id}
65+ })
66+}
67+
68+type ColorCycleMsg struct {
69+ id string
70+}
71+
72+func cycleColors(id string) tea.Cmd {
73+ return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
74+ return ColorCycleMsg{id}
75+ })
76+}
77+
78+type Animation interface {
79+ util.Model
80+ ID() string
81+}
82+
83+// anim is the model that manages the animation that displays while the
84+// output is being generated.
85+type anim struct {
86+ start time.Time
87+ cyclingChars []cyclingChar
88+ labelChars []cyclingChar
89+ ramp []lipgloss.Style
90+ label []rune
91+ ellipsis spinner.Model
92+ ellipsisStarted bool
93+ id string
94+}
95+
96+type animOption func(*anim)
97+
98+func WithId(id string) animOption {
99+ return func(a *anim) {
100+ a.id = id
101+ }
102+}
103+
104+func New(cyclingCharsSize uint, label string, opts ...animOption) Animation {
105+ // #nosec G115
106+ n := min(int(cyclingCharsSize), maxCyclingChars)
107+
108+ gap := " "
109+ if n == 0 {
110+ gap = ""
111+ }
112+
113+ id := uuid.New()
114+ c := anim{
115+ start: time.Now(),
116+ label: []rune(gap + label),
117+ ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
118+ id: id.String(),
119+ }
120+
121+ for _, opt := range opts {
122+ opt(&c)
123+ }
124+
125+ // If we're in truecolor mode (and there are enough cycling characters)
126+ // color the cycling characters with a gradient ramp.
127+ const minRampSize = 3
128+ if n >= minRampSize {
129+ // Note: double capacity for color cycling as we'll need to reverse and
130+ // append the ramp for seamless transitions.
131+ c.ramp = make([]lipgloss.Style, n, n*2) //nolint:mnd
132+ ramp := makeGradientRamp(n)
133+ for i, color := range ramp {
134+ c.ramp[i] = lipgloss.NewStyle().Foreground(color)
135+ }
136+ c.ramp = append(c.ramp, reverse(c.ramp)...) // reverse and append for color cycling
137+ }
138+
139+ makeDelay := func(a int32, b time.Duration) time.Duration {
140+ return time.Duration(rand.Int32N(a)) * (time.Millisecond * b) //nolint:gosec
141+ }
142+
143+ makeInitialDelay := func() time.Duration {
144+ return makeDelay(8, 60) //nolint:mnd
145+ }
146+
147+ // Initial characters that cycle forever.
148+ c.cyclingChars = make([]cyclingChar, n)
149+
150+ for i := range n {
151+ c.cyclingChars[i] = cyclingChar{
152+ finalValue: -1, // cycle forever
153+ initialDelay: makeInitialDelay(),
154+ }
155+ }
156+
157+ // Label text that only cycles for a little while.
158+ c.labelChars = make([]cyclingChar, len(c.label))
159+
160+ for i, r := range c.label {
161+ c.labelChars[i] = cyclingChar{
162+ finalValue: r,
163+ initialDelay: makeInitialDelay(),
164+ lifetime: makeDelay(5, 180), //nolint:mnd
165+ }
166+ }
167+
168+ return c
169+}
170+
171+// Init initializes the animation.
172+func (a anim) Init() tea.Cmd {
173+ return tea.Batch(stepChars(a.id), cycleColors(a.id))
174+}
175+
176+// Update handles messages.
177+func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
178+ var cmd tea.Cmd
179+ switch msg := msg.(type) {
180+ case StepCharsMsg:
181+ if msg.id != a.id {
182+ return a, nil
183+ }
184+ a.updateChars(&a.cyclingChars)
185+ a.updateChars(&a.labelChars)
186+
187+ if !a.ellipsisStarted {
188+ var eol int
189+ for _, c := range a.labelChars {
190+ if c.state(a.start) == charEndOfLifeState {
191+ eol++
192+ }
193+ }
194+ if eol == len(a.label) {
195+ // If our entire label has reached end of life, start the
196+ // ellipsis "spinner" after a short pause.
197+ a.ellipsisStarted = true
198+ cmd = tea.Tick(time.Millisecond*220, func(time.Time) tea.Msg { //nolint:mnd
199+ return a.ellipsis.Tick()
200+ })
201+ }
202+ }
203+
204+ return a, tea.Batch(stepChars(a.id), cmd)
205+ case ColorCycleMsg:
206+ if msg.id != a.id {
207+ return a, nil
208+ }
209+ const minColorCycleSize = 2
210+ if len(a.ramp) < minColorCycleSize {
211+ return a, nil
212+ }
213+ a.ramp = append(a.ramp[1:], a.ramp[0])
214+ return a, cycleColors(a.id)
215+ case spinner.TickMsg:
216+ var cmd tea.Cmd
217+ a.ellipsis, cmd = a.ellipsis.Update(msg)
218+ return a, cmd
219+ default:
220+ return a, nil
221+ }
222+}
223+
224+func (a anim) ID() string {
225+ return a.id
226+}
227+
228+func (a *anim) updateChars(chars *[]cyclingChar) {
229+ charSlice := *chars // dereference to avoid repeated pointer access
230+ for i, c := range charSlice {
231+ switch c.state(a.start) {
232+ case charInitialState:
233+ charSlice[i].currentValue = '.'
234+ case charCyclingState:
235+ charSlice[i].currentValue = c.randomRune()
236+ case charEndOfLifeState:
237+ charSlice[i].currentValue = c.finalValue
238+ }
239+ }
240+}
241+
242+// View renders the animation.
243+func (a anim) View() tea.View {
244+ var (
245+ t = styles.CurrentTheme()
246+ b strings.Builder
247+ )
248+
249+ // Pre-allocate builder capacity to avoid reallocations.
250+ // Estimate: cycling chars + label chars + ellipsis + style overhead.
251+ const (
252+ bytesPerChar = 20 // ANSI styling
253+ bufferSize = 50 // ellipsis and safety margin
254+ )
255+ estimatedCap := len(a.cyclingChars)*bytesPerChar + len(a.labelChars)*bytesPerChar + bufferSize
256+ b.Grow(estimatedCap)
257+
258+ for i, c := range a.cyclingChars {
259+ if len(a.ramp) > i {
260+ b.WriteString(a.ramp[i].Render(string(c.currentValue)))
261+ continue
262+ }
263+ b.WriteRune(c.currentValue)
264+ }
265+
266+ if len(a.labelChars) > 1 {
267+ textStyle := t.S().Text
268+ for _, c := range a.labelChars {
269+ b.WriteString(
270+ textStyle.Render(string(c.currentValue)),
271+ )
272+ }
273+ b.WriteString(textStyle.Render(a.ellipsis.View()))
274+ }
275+
276+ return tea.NewView(b.String())
277+}
278+
279+func GetColor(c color.Color) string {
280+ rgba := color.RGBAModel.Convert(c).(color.RGBA)
281+ return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
282+}
283+
284+func makeGradientRamp(length int) []color.Color {
285+ t := styles.CurrentTheme()
286+ startColor := GetColor(t.Primary)
287+ endColor := GetColor(t.Secondary)
288+ var (
289+ c = make([]color.Color, length)
290+ start, _ = colorful.Hex(startColor)
291+ end, _ = colorful.Hex(endColor)
292+ )
293+ for i := range length {
294+ step := start.BlendLuv(end, float64(i)/float64(length))
295+ c[i] = lipgloss.Color(step.Hex())
296+ }
297+ return c
298+}
299+
300+func reverse[T any](in []T) []T {
301+ out := make([]T, len(in))
302+ copy(out, in[:])
303+ for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
304+ out[i], out[j] = out[j], out[i]
305+ }
306+ return out
307+}
internal/tui/components/chat/chat.go+462−106
@@ -1,17 +1,20 @@
11package chat
22
33import (
4− "fmt"
5− "sort"
6−
7− "github.com/charmbracelet/lipgloss"
8− "github.com/charmbracelet/x/ansi"
9− "github.com/opencode-ai/opencode/internal/config"
10− "github.com/opencode-ai/opencode/internal/message"
11− "github.com/opencode-ai/opencode/internal/session"
12− "github.com/opencode-ai/opencode/internal/tui/styles"
13− "github.com/opencode-ai/opencode/internal/tui/theme"
14− "github.com/opencode-ai/opencode/internal/version"
4+ "context"
5+ "time"
6+
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/app"
9+ "github.com/charmbracelet/crush/internal/llm/agent"
10+ "github.com/charmbracelet/crush/internal/message"
11+ "github.com/charmbracelet/crush/internal/pubsub"
12+ "github.com/charmbracelet/crush/internal/session"
13+ "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
14+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
15+ "github.com/charmbracelet/crush/internal/tui/layout"
16+ "github.com/charmbracelet/crush/internal/tui/util"
17+ "github.com/charmbracelet/lipgloss/v2"
1518)
1619
1720type SendMsg struct {
@@ -23,119 +26,472 @@ type SessionSelectedMsg = session.Session
2326
2427type SessionClearedMsg struct{}
2528
26−type EditorFocusMsg bool
29+const (
30+ NotFound = -1
31+)
32+
33+// MessageListCmp represents a component that displays a list of chat messages
34+// with support for real-time updates and session management.
35+type MessageListCmp interface {
36+ util.Model
37+ layout.Sizeable
38+ layout.Focusable
39+}
40+
41+// messageListCmp implements MessageListCmp, providing a virtualized list
42+// of chat messages with support for tool calls, real-time updates, and
43+// session switching.
44+type messageListCmp struct {
45+ app *app.App
46+ width, height int
47+ session session.Session
48+ listCmp list.ListModel
49+ previousSelected int // Last selected item index for restoring focus
50+
51+ lastUserMessageTime int64
52+}
53+
54+// NewMessagesListCmp creates a new message list component with custom keybindings
55+// and reverse ordering (newest messages at bottom).
56+func NewMessagesListCmp(app *app.App) MessageListCmp {
57+ defaultKeymaps := list.DefaultKeyMap()
58+ listCmp := list.New(
59+ list.WithGapSize(1),
60+ list.WithReverse(true),
61+ list.WithKeyMap(defaultKeymaps),
62+ )
63+ return &messageListCmp{
64+ app: app,
65+ listCmp: listCmp,
66+ previousSelected: list.NoSelection,
67+ }
68+}
69+
70+// Init initializes the component (no initialization needed).
71+func (m *messageListCmp) Init() tea.Cmd {
72+ return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
73+}
74+
75+// Update handles incoming messages and updates the component state.
76+func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77+ switch msg := msg.(type) {
78+ case SessionSelectedMsg:
79+ if msg.ID != m.session.ID {
80+ cmd := m.SetSession(msg)
81+ return m, cmd
82+ }
83+ return m, nil
84+ case SessionClearedMsg:
85+ m.session = session.Session{}
86+ return m, m.listCmp.SetItems([]util.Model{})
2787
28−func header(width int) string {
29− return lipgloss.JoinVertical(
30− lipgloss.Top,
31− logo(width),
32− repo(width),
33− "",
34− cwd(width),
88+ case pubsub.Event[message.Message]:
89+ cmd := m.handleMessageEvent(msg)
90+ return m, cmd
91+ default:
92+ var cmds []tea.Cmd
93+ u, cmd := m.listCmp.Update(msg)
94+ m.listCmp = u.(list.ListModel)
95+ cmds = append(cmds, cmd)
96+ return m, tea.Batch(cmds...)
97+ }
98+}
99+
100+// View renders the message list or an initial screen if empty.
101+func (m *messageListCmp) View() tea.View {
102+ return tea.NewView(
103+ lipgloss.JoinVertical(
104+ lipgloss.Left,
105+ m.listCmp.View().String(),
106+ ),
107+ )
108+}
109+
110+// handleChildSession handles messages from child sessions (agent tools).
111+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
112+ var cmds []tea.Cmd
113+ if len(event.Payload.ToolCalls()) == 0 {
114+ return nil
115+ }
116+ items := m.listCmp.Items()
117+ toolCallInx := NotFound
118+ var toolCall messages.ToolCallCmp
119+ for i := len(items) - 1; i >= 0; i-- {
120+ if msg, ok := items[i].(messages.ToolCallCmp); ok {
121+ if msg.GetToolCall().ID == event.Payload.SessionID {
122+ toolCallInx = i
123+ toolCall = msg
124+ }
125+ }
126+ }
127+ if toolCallInx == NotFound {
128+ return nil
129+ }
130+ nestedToolCalls := toolCall.GetNestedToolCalls()
131+ for _, tc := range event.Payload.ToolCalls() {
132+ found := false
133+ for existingInx, existingTC := range nestedToolCalls {
134+ if existingTC.GetToolCall().ID == tc.ID {
135+ nestedToolCalls[existingInx].SetToolCall(tc)
136+ found = true
137+ break
138+ }
139+ }
140+ if !found {
141+ nestedCall := messages.NewToolCallCmp(
142+ event.Payload.ID,
143+ tc,
144+ messages.WithToolCallNested(true),
145+ )
146+ cmds = append(cmds, nestedCall.Init())
147+ nestedToolCalls = append(
148+ nestedToolCalls,
149+ nestedCall,
150+ )
151+ }
152+ }
153+ toolCall.SetNestedToolCalls(nestedToolCalls)
154+ m.listCmp.UpdateItem(
155+ toolCallInx,
156+ toolCall,
35157 )
158+ return tea.Batch(cmds...)
36159}
37160
38−func lspsConfigured(width int) string {
39− cfg := config.Get()
40− title := "LSP Configuration"
41− title = ansi.Truncate(title, width, "…")
42−
43− t := theme.CurrentTheme()
44− baseStyle := styles.BaseStyle()
45−
46− lsps := baseStyle.
47− Width(width).
48− Foreground(t.Primary()).
49− Bold(true).
50− Render(title)
51−
52− // Get LSP names and sort them for consistent ordering
53− var lspNames []string
54− for name := range cfg.LSP {
55− lspNames = append(lspNames, name)
56− }
57− sort.Strings(lspNames)
58−
59− var lspViews []string
60− for _, name := range lspNames {
61− lsp := cfg.LSP[name]
62− lspName := baseStyle.
63− Foreground(t.Text()).
64− Render(fmt.Sprintf("• %s", name))
65−
66− cmd := lsp.Command
67− cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
68−
69− lspPath := baseStyle.
70− Foreground(t.TextMuted()).
71− Render(fmt.Sprintf(" (%s)", cmd))
72−
73− lspViews = append(lspViews,
74− baseStyle.
75− Width(width).
76− Render(
77− lipgloss.JoinHorizontal(
78− lipgloss.Left,
79− lspName,
80− lspPath,
81− ),
82− ),
161+// handleMessageEvent processes different types of message events (created/updated).
162+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
163+ switch event.Type {
164+ case pubsub.CreatedEvent:
165+ if event.Payload.SessionID != m.session.ID {
166+ return m.handleChildSession(event)
167+ }
168+ if m.messageExists(event.Payload.ID) {
169+ return nil
170+ }
171+ return m.handleNewMessage(event.Payload)
172+ case pubsub.UpdatedEvent:
173+ if event.Payload.SessionID != m.session.ID {
174+ return m.handleChildSession(event)
175+ }
176+ return m.handleUpdateAssistantMessage(event.Payload)
177+ }
178+ return nil
179+}
180+
181+// messageExists checks if a message with the given ID already exists in the list.
182+func (m *messageListCmp) messageExists(messageID string) bool {
183+ items := m.listCmp.Items()
184+ // Search backwards as new messages are more likely to be at the end
185+ for i := len(items) - 1; i >= 0; i-- {
186+ if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
187+ return true
188+ }
189+ }
190+ return false
191+}
192+
193+// handleNewMessage routes new messages to appropriate handlers based on role.
194+func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
195+ switch msg.Role {
196+ case message.User:
197+ return m.handleNewUserMessage(msg)
198+ case message.Assistant:
199+ return m.handleNewAssistantMessage(msg)
200+ case message.Tool:
201+ return m.handleToolMessage(msg)
202+ }
203+ return nil
204+}
205+
206+// handleNewUserMessage adds a new user message to the list and updates the timestamp.
207+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
208+ m.lastUserMessageTime = msg.CreatedAt
209+ return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
210+}
211+
212+// handleToolMessage updates existing tool calls with their results.
213+func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
214+ items := m.listCmp.Items()
215+ for _, tr := range msg.ToolResults() {
216+ if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
217+ toolCall := items[toolCallIndex].(messages.ToolCallCmp)
218+ toolCall.SetToolResult(tr)
219+ m.listCmp.UpdateItem(toolCallIndex, toolCall)
220+ }
221+ }
222+ return nil
223+}
224+
225+// findToolCallByID searches for a tool call with the specified ID.
226+// Returns the index if found, NotFound otherwise.
227+func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
228+ // Search backwards as tool calls are more likely to be recent
229+ for i := len(items) - 1; i >= 0; i-- {
230+ if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
231+ return i
232+ }
233+ }
234+ return NotFound
235+}
236+
237+// handleUpdateAssistantMessage processes updates to assistant messages,
238+// managing both message content and associated tool calls.
239+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
240+ var cmds []tea.Cmd
241+ items := m.listCmp.Items()
242+
243+ // Find existing assistant message and tool calls for this message
244+ assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
245+
246+ // Handle assistant message content
247+ if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
248+ cmds = append(cmds, cmd)
249+ }
250+
251+ // Handle tool calls
252+ if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
253+ cmds = append(cmds, cmd)
254+ }
255+
256+ return tea.Batch(cmds...)
257+}
258+
259+// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
260+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
261+ assistantIndex := NotFound
262+ toolCalls := make(map[int]messages.ToolCallCmp)
263+
264+ // Search backwards as messages are more likely to be at the end
265+ for i := len(items) - 1; i >= 0; i-- {
266+ item := items[i]
267+ if asMsg, ok := item.(messages.MessageCmp); ok {
268+ if asMsg.GetMessage().ID == messageID {
269+ assistantIndex = i
270+ }
271+ } else if tc, ok := item.(messages.ToolCallCmp); ok {
272+ if tc.ParentMessageId() == messageID {
273+ toolCalls[i] = tc
274+ }
275+ }
276+ }
277+
278+ return assistantIndex, toolCalls
279+}
280+
281+// updateAssistantMessageContent updates or removes the assistant message based on content.
282+func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
283+ if assistantIndex == NotFound {
284+ return nil
285+ }
286+
287+ shouldShowMessage := m.shouldShowAssistantMessage(msg)
288+ hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
289+
290+ if shouldShowMessage {
291+ m.listCmp.UpdateItem(
292+ assistantIndex,
293+ messages.NewMessageCmp(
294+ msg,
295+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
296+ ),
83297 )
298+ } else if hasToolCallsOnly {
299+ m.listCmp.DeleteItem(assistantIndex)
300+ }
301+
302+ return nil
303+}
304+
305+// shouldShowAssistantMessage determines if an assistant message should be displayed.
306+func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
307+ return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
308+}
309+
310+// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
311+func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
312+ var cmds []tea.Cmd
313+
314+ for _, tc := range msg.ToolCalls() {
315+ if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
316+ cmds = append(cmds, cmd)
317+ }
318+ }
319+
320+ return tea.Batch(cmds...)
321+}
322+
323+// updateOrAddToolCall updates an existing tool call or adds a new one.
324+func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
325+ // Try to find existing tool call
326+ for index, existingTC := range existingToolCalls {
327+ if tc.ID == existingTC.GetToolCall().ID {
328+ existingTC.SetToolCall(tc)
329+ m.listCmp.UpdateItem(index, existingTC)
330+ return nil
331+ }
84332 }
85333
86− return baseStyle.
87− Width(width).
88− Render(
89− lipgloss.JoinVertical(
90− lipgloss.Left,
91− lsps,
92− lipgloss.JoinVertical(
93− lipgloss.Left,
94− lspViews...,
95− ),
334+ // Add new tool call if not found
335+ return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
336+}
337+
338+// handleNewAssistantMessage processes new assistant messages and their tool calls.
339+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
340+ var cmds []tea.Cmd
341+
342+ // Add assistant message if it should be displayed
343+ if m.shouldShowAssistantMessage(msg) {
344+ cmd := m.listCmp.AppendItem(
345+ messages.NewMessageCmp(
346+ msg,
347+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
96348 ),
97349 )
350+ cmds = append(cmds, cmd)
351+ }
352+
353+ // Add tool calls
354+ for _, tc := range msg.ToolCalls() {
355+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
356+ cmds = append(cmds, cmd)
357+ }
358+
359+ return tea.Batch(cmds...)
98360}
99361
100−func logo(width int) string {
101− logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
102− t := theme.CurrentTheme()
103− baseStyle := styles.BaseStyle()
104−
105− versionText := baseStyle.
106− Foreground(t.TextMuted()).
107− Render(version.Version)
108−
109− return baseStyle.
110− Bold(true).
111− Width(width).
112− Render(
113− lipgloss.JoinHorizontal(
114− lipgloss.Left,
115− logo,
116− " ",
117− versionText,
362+// SetSession loads and displays messages for a new session.
363+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
364+ if m.session.ID == session.ID {
365+ return nil
366+ }
367+
368+ m.session = session
369+ sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
370+ if err != nil {
371+ return util.ReportError(err)
372+ }
373+
374+ if len(sessionMessages) == 0 {
375+ return m.listCmp.SetItems([]util.Model{})
376+ }
377+
378+ // Initialize with first message timestamp
379+ m.lastUserMessageTime = sessionMessages[0].CreatedAt
380+
381+ // Build tool result map for efficient lookup
382+ toolResultMap := m.buildToolResultMap(sessionMessages)
383+
384+ // Convert messages to UI components
385+ uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
386+
387+ return m.listCmp.SetItems(uiMessages)
388+}
389+
390+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
391+func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
392+ toolResultMap := make(map[string]message.ToolResult)
393+ for _, msg := range messages {
394+ for _, tr := range msg.ToolResults() {
395+ toolResultMap[tr.ToolCallID] = tr
396+ }
397+ }
398+ return toolResultMap
399+}
400+
401+// convertMessagesToUI converts database messages to UI components.
402+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
403+ uiMessages := make([]util.Model, 0)
404+
405+ for _, msg := range sessionMessages {
406+ switch msg.Role {
407+ case message.User:
408+ m.lastUserMessageTime = msg.CreatedAt
409+ uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
410+ case message.Assistant:
411+ uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
412+ }
413+ }
414+
415+ return uiMessages
416+}
417+
418+// convertAssistantMessage converts an assistant message and its tool calls to UI components.
419+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
420+ var uiMessages []util.Model
421+
422+ // Add assistant message if it should be displayed
423+ if m.shouldShowAssistantMessage(msg) {
424+ uiMessages = append(
425+ uiMessages,
426+ messages.NewMessageCmp(
427+ msg,
428+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
118429 ),
119430 )
431+ }
432+
433+ // Add tool calls with their results and status
434+ for _, tc := range msg.ToolCalls() {
435+ options := m.buildToolCallOptions(tc, msg, toolResultMap)
436+ uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
437+ // If this tool call is the agent tool, fetch nested tool calls
438+ if tc.Name == agent.AgentToolName {
439+ nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
440+ nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
441+ nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
442+ for _, nestedMsg := range nestedUIMessages {
443+ if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
444+ toolCall.SetIsNested(true)
445+ nestedToolCalls = append(nestedToolCalls, toolCall)
446+ }
447+ }
448+ uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
449+ }
450+ }
451+
452+ return uiMessages
120453}
121454
122−func repo(width int) string {
123− repo := "https://github.com/opencode-ai/opencode"
124− t := theme.CurrentTheme()
455+// buildToolCallOptions creates options for tool call components based on results and status.
456+func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
457+ var options []messages.ToolCallOption
458+
459+ // Add tool result if available
460+ if tr, ok := toolResultMap[tc.ID]; ok {
461+ options = append(options, messages.WithToolCallResult(tr))
462+ }
463+
464+ // Add cancelled status if applicable
465+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
466+ options = append(options, messages.WithToolCallCancelled())
467+ }
125468
126− return styles.BaseStyle().
127− Foreground(t.TextMuted()).
128− Width(width).
129− Render(repo)
469+ return options
130470}
131471
132−func cwd(width int) string {
133− cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
134− t := theme.CurrentTheme()
472+// GetSize returns the current width and height of the component.
473+func (m *messageListCmp) GetSize() (int, int) {
474+ return m.width, m.height
475+}
476+
477+// SetSize updates the component dimensions and propagates to the list component.
478+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
479+ m.width = width
480+ m.height = height - 1
481+ return m.listCmp.SetSize(width, height-1)
482+}
135483
136− return styles.BaseStyle().
137− Foreground(t.TextMuted()).
138− Width(width).
139− Render(cwd)
484+// Blur implements MessageListCmp.
485+func (m *messageListCmp) Blur() tea.Cmd {
486+ return m.listCmp.Blur()
140487}
141488
489+// Focus implements MessageListCmp.
490+func (m *messageListCmp) Focus() tea.Cmd {
491+ return m.listCmp.Focus()
492+}
493+
494+// IsFocused implements MessageListCmp.
495+func (m *messageListCmp) IsFocused() bool {
496+ return m.listCmp.IsFocused()
497+}
internal/tui/components/chat/editor.godeleted−318
@@ -1,318 +0,0 @@
1−package chat
2−
3−import (
4− "fmt"
5− "os"
6− "os/exec"
7− "slices"
8− "strings"
9− "unicode"
10−
11− "github.com/charmbracelet/bubbles/key"
12− "github.com/charmbracelet/bubbles/textarea"
13− tea "github.com/charmbracelet/bubbletea"
14− "github.com/charmbracelet/lipgloss"
15− "github.com/opencode-ai/opencode/internal/app"
16− "github.com/opencode-ai/opencode/internal/logging"
17− "github.com/opencode-ai/opencode/internal/message"
18− "github.com/opencode-ai/opencode/internal/session"
19− "github.com/opencode-ai/opencode/internal/tui/components/dialog"
20− "github.com/opencode-ai/opencode/internal/tui/layout"
21− "github.com/opencode-ai/opencode/internal/tui/styles"
22− "github.com/opencode-ai/opencode/internal/tui/theme"
23− "github.com/opencode-ai/opencode/internal/tui/util"
24−)
25−
26−type editorCmp struct {
27− width int
28− height int
29− app *app.App
30− session session.Session
31− textarea textarea.Model
32− attachments []message.Attachment
33− deleteMode bool
34−}
35−
36−type EditorKeyMaps struct {
37− Send key.Binding
38− OpenEditor key.Binding
39−}
40−
41−type bluredEditorKeyMaps struct {
42− Send key.Binding
43− Focus key.Binding
44− OpenEditor key.Binding
45−}
46−type DeleteAttachmentKeyMaps struct {
47− AttachmentDeleteMode key.Binding
48− Escape key.Binding
49− DeleteAllAttachments key.Binding
50−}
51−
52−var editorMaps = EditorKeyMaps{
53− Send: key.NewBinding(
54− key.WithKeys("enter", "ctrl+s"),
55− key.WithHelp("enter", "send message"),
56− ),
57− OpenEditor: key.NewBinding(
58− key.WithKeys("ctrl+e"),
59− key.WithHelp("ctrl+e", "open editor"),
60− ),
61−}
62−
63−var DeleteKeyMaps = DeleteAttachmentKeyMaps{
64− AttachmentDeleteMode: key.NewBinding(
65− key.WithKeys("ctrl+r"),
66− key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
67− ),
68− Escape: key.NewBinding(
69− key.WithKeys("esc"),
70− key.WithHelp("esc", "cancel delete mode"),
71− ),
72− DeleteAllAttachments: key.NewBinding(
73− key.WithKeys("r"),
74− key.WithHelp("ctrl+r+r", "delete all attchments"),
75− ),
76−}
77−
78−const (
79− maxAttachments = 5
80−)
81−
82−func (m *editorCmp) openEditor() tea.Cmd {
83− editor := os.Getenv("EDITOR")
84− if editor == "" {
85− editor = "nvim"
86− }
87−
88− tmpfile, err := os.CreateTemp("", "msg_*.md")
89− if err != nil {
90− return util.ReportError(err)
91− }
92− tmpfile.Close()
93− c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
94− c.Stdin = os.Stdin
95− c.Stdout = os.Stdout
96− c.Stderr = os.Stderr
97− return tea.ExecProcess(c, func(err error) tea.Msg {
98− if err != nil {
99− return util.ReportError(err)
100− }
101− content, err := os.ReadFile(tmpfile.Name())
102− if err != nil {
103− return util.ReportError(err)
104− }
105− if len(content) == 0 {
106− return util.ReportWarn("Message is empty")
107− }
108− os.Remove(tmpfile.Name())
109− attachments := m.attachments
110− m.attachments = nil
111− return SendMsg{
112− Text: string(content),
113− Attachments: attachments,
114− }
115− })
116−}
117−
118−func (m *editorCmp) Init() tea.Cmd {
119− return textarea.Blink
120−}
121−
122−func (m *editorCmp) send() tea.Cmd {
123− if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
124− return util.ReportWarn("Agent is working, please wait...")
125− }
126−
127− value := m.textarea.Value()
128− m.textarea.Reset()
129− attachments := m.attachments
130−
131− m.attachments = nil
132− if value == "" {
133− return nil
134− }
135− return tea.Batch(
136− util.CmdHandler(SendMsg{
137− Text: value,
138− Attachments: attachments,
139− }),
140− )
141−}
142−
143−func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
144− var cmd tea.Cmd
145− switch msg := msg.(type) {
146− case dialog.ThemeChangedMsg:
147− m.textarea = CreateTextArea(&m.textarea)
148− case dialog.CompletionSelectedMsg:
149− existingValue := m.textarea.Value()
150− modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
151−
152− m.textarea.SetValue(modifiedValue)
153− return m, nil
154− case SessionSelectedMsg:
155− if msg.ID != m.session.ID {
156− m.session = msg
157− }
158− return m, nil
159− case dialog.AttachmentAddedMsg:
160− if len(m.attachments) >= maxAttachments {
161− logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
162− return m, cmd
163− }
164− m.attachments = append(m.attachments, msg.Attachment)
165− case tea.KeyMsg:
166− if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
167− m.deleteMode = true
168− return m, nil
169− }
170− if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
171− m.deleteMode = false
172− m.attachments = nil
173− return m, nil
174− }
175− if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
176− num := int(msg.Runes[0] - '0')
177− m.deleteMode = false
178− if num < 10 && len(m.attachments) > num {
179− if num == 0 {
180− m.attachments = m.attachments[num+1:]
181− } else {
182− m.attachments = slices.Delete(m.attachments, num, num+1)
183− }
184− return m, nil
185− }
186− }
187− if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
188− key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
189− return m, nil
190− }
191− if key.Matches(msg, editorMaps.OpenEditor) {
192− if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
193− return m, util.ReportWarn("Agent is working, please wait...")
194− }
195− return m, m.openEditor()
196− }
197− if key.Matches(msg, DeleteKeyMaps.Escape) {
198− m.deleteMode = false
199− return m, nil
200− }
201− // Hanlde Enter key
202− if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
203− value := m.textarea.Value()
204− if len(value) > 0 && value[len(value)-1] == '\\' {
205− // If the last character is a backslash, remove it and add a newline
206− m.textarea.SetValue(value[:len(value)-1] + "\n")
207− return m, nil
208− } else {
209− // Otherwise, send the message
210− return m, m.send()
211− }
212− }
213−
214− }
215− m.textarea, cmd = m.textarea.Update(msg)
216− return m, cmd
217−}
218−
219−func (m *editorCmp) View() string {
220− t := theme.CurrentTheme()
221−
222− // Style the prompt with theme colors
223− style := lipgloss.NewStyle().
224− Padding(0, 0, 0, 1).
225− Bold(true).
226− Foreground(t.Primary())
227−
228− if len(m.attachments) == 0 {
229− return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
230− }
231− m.textarea.SetHeight(m.height - 1)
232− return lipgloss.JoinVertical(lipgloss.Top,
233− m.attachmentsContent(),
234− lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
235− m.textarea.View()),
236− )
237−}
238−
239−func (m *editorCmp) SetSize(width, height int) tea.Cmd {
240− m.width = width
241− m.height = height
242− m.textarea.SetWidth(width - 3) // account for the prompt and padding right
243− m.textarea.SetHeight(height)
244− m.textarea.SetWidth(width)
245− return nil
246−}
247−
248−func (m *editorCmp) GetSize() (int, int) {
249− return m.textarea.Width(), m.textarea.Height()
250−}
251−
252−func (m *editorCmp) attachmentsContent() string {
253− var styledAttachments []string
254− t := theme.CurrentTheme()
255− attachmentStyles := styles.BaseStyle().
256− MarginLeft(1).
257− Background(t.TextMuted()).
258− Foreground(t.Text())
259− for i, attachment := range m.attachments {
260− var filename string
261− if len(attachment.FileName) > 10 {
262− filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
263− } else {
264− filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
265− }
266− if m.deleteMode {
267− filename = fmt.Sprintf("%d%s", i, filename)
268− }
269− styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
270− }
271− content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
272− return content
273−}
274−
275−func (m *editorCmp) BindingKeys() []key.Binding {
276− bindings := []key.Binding{}
277− bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
278− bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
279− return bindings
280−}
281−
282−func CreateTextArea(existing *textarea.Model) textarea.Model {
283− t := theme.CurrentTheme()
284− bgColor := t.Background()
285− textColor := t.Text()
286− textMutedColor := t.TextMuted()
287−
288− ta := textarea.New()
289− ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
290− ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
291− ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
292− ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
293− ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
294− ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
295− ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
296− ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
297−
298− ta.Prompt = " "
299− ta.ShowLineNumbers = false
300− ta.CharLimit = -1
301−
302− if existing != nil {
303− ta.SetValue(existing.Value())
304− ta.SetWidth(existing.Width())
305− ta.SetHeight(existing.Height())
306− }
307−
308− ta.Focus()
309− return ta
310−}
311−
312−func NewEditorCmp(app *app.App) tea.Model {
313− ta := CreateTextArea(nil)
314− return &editorCmp{
315− app: app,
316− textarea: ta,
317− }
318−}
internal/tui/components/chat/editor/editor.gonew file+396
@@ -0,0 +1,396 @@
1+package editor
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+ "slices"
8+ "strings"
9+ "unicode"
10+
11+ "github.com/charmbracelet/bubbles/v2/key"
12+ "github.com/charmbracelet/bubbles/v2/textarea"
13+ tea "github.com/charmbracelet/bubbletea/v2"
14+ "github.com/charmbracelet/crush/internal/app"
15+ "github.com/charmbracelet/crush/internal/fileutil"
16+ "github.com/charmbracelet/crush/internal/logging"
17+ "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/session"
19+ "github.com/charmbracelet/crush/internal/tui/components/chat"
20+ "github.com/charmbracelet/crush/internal/tui/components/completions"
21+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
22+ "github.com/charmbracelet/crush/internal/tui/layout"
23+ "github.com/charmbracelet/crush/internal/tui/styles"
24+ "github.com/charmbracelet/crush/internal/tui/util"
25+ "github.com/charmbracelet/lipgloss/v2"
26+)
27+
28+type FileCompletionItem struct {
29+ Path string // The file path
30+}
31+
32+type editorCmp struct {
33+ width int
34+ height int
35+ x, y int
36+ app *app.App
37+ session session.Session
38+ textarea textarea.Model
39+ attachments []message.Attachment
40+ deleteMode bool
41+
42+ keyMap EditorKeyMap
43+
44+ // File path completions
45+ currentQuery string
46+ completionsStartIndex int
47+ isCompletionsOpen bool
48+}
49+
50+type DeleteAttachmentKeyMaps struct {
51+ AttachmentDeleteMode key.Binding
52+ Escape key.Binding
53+ DeleteAllAttachments key.Binding
54+}
55+
56+var DeleteKeyMaps = DeleteAttachmentKeyMaps{
57+ AttachmentDeleteMode: key.NewBinding(
58+ key.WithKeys("ctrl+r"),
59+ key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
60+ ),
61+ Escape: key.NewBinding(
62+ key.WithKeys("esc"),
63+ key.WithHelp("esc", "cancel delete mode"),
64+ ),
65+ DeleteAllAttachments: key.NewBinding(
66+ key.WithKeys("r"),
67+ key.WithHelp("ctrl+r+r", "delete all attchments"),
68+ ),
69+}
70+
71+const (
72+ maxAttachments = 5
73+)
74+
75+func (m *editorCmp) openEditor() tea.Cmd {
76+ editor := os.Getenv("EDITOR")
77+ if editor == "" {
78+ editor = "nvim"
79+ }
80+
81+ tmpfile, err := os.CreateTemp("", "msg_*.md")
82+ if err != nil {
83+ return util.ReportError(err)
84+ }
85+ tmpfile.Close()
86+ c := exec.Command(editor, tmpfile.Name())
87+ c.Stdin = os.Stdin
88+ c.Stdout = os.Stdout
89+ c.Stderr = os.Stderr
90+ return tea.ExecProcess(c, func(err error) tea.Msg {
91+ if err != nil {
92+ return util.ReportError(err)
93+ }
94+ content, err := os.ReadFile(tmpfile.Name())
95+ if err != nil {
96+ return util.ReportError(err)
97+ }
98+ if len(content) == 0 {
99+ return util.ReportWarn("Message is empty")
100+ }
101+ os.Remove(tmpfile.Name())
102+ attachments := m.attachments
103+ m.attachments = nil
104+ return chat.SendMsg{
105+ Text: string(content),
106+ Attachments: attachments,
107+ }
108+ })
109+}
110+
111+func (m *editorCmp) Init() tea.Cmd {
112+ return nil
113+}
114+
115+func (m *editorCmp) send() tea.Cmd {
116+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
117+ return util.ReportWarn("Agent is working, please wait...")
118+ }
119+
120+ value := m.textarea.Value()
121+ m.textarea.Reset()
122+ attachments := m.attachments
123+
124+ m.attachments = nil
125+ if value == "" {
126+ return nil
127+ }
128+ return tea.Batch(
129+ util.CmdHandler(chat.SendMsg{
130+ Text: value,
131+ Attachments: attachments,
132+ }),
133+ )
134+}
135+
136+func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
137+ var cmd tea.Cmd
138+ var cmds []tea.Cmd
139+ switch msg := msg.(type) {
140+ case chat.SessionSelectedMsg:
141+ if msg.ID != m.session.ID {
142+ m.session = msg
143+ }
144+ return m, nil
145+ case filepicker.FilePickedMsg:
146+ if len(m.attachments) >= maxAttachments {
147+ logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
148+ return m, cmd
149+ }
150+ m.attachments = append(m.attachments, msg.Attachment)
151+ return m, nil
152+ case completions.CompletionsClosedMsg:
153+ m.isCompletionsOpen = false
154+ m.currentQuery = ""
155+ m.completionsStartIndex = 0
156+ case completions.SelectCompletionMsg:
157+ if !m.isCompletionsOpen {
158+ return m, nil
159+ }
160+ if item, ok := msg.Value.(FileCompletionItem); ok {
161+ // If the selected item is a file, insert its path into the textarea
162+ value := m.textarea.Value()
163+ value = value[:m.completionsStartIndex]
164+ if len(value) > 0 && value[len(value)-1] != ' ' {
165+ value += " "
166+ }
167+ value += item.Path
168+ m.textarea.SetValue(value)
169+ m.isCompletionsOpen = false
170+ m.currentQuery = ""
171+ m.completionsStartIndex = 0
172+ return m, nil
173+ }
174+ case tea.KeyPressMsg:
175+ switch {
176+ // Completions
177+ case msg.String() == "/" && !m.isCompletionsOpen:
178+ m.isCompletionsOpen = true
179+ m.currentQuery = ""
180+ cmds = append(cmds, m.startCompletions)
181+ m.completionsStartIndex = len(m.textarea.Value())
182+ case msg.String() == "space" && m.isCompletionsOpen:
183+ m.isCompletionsOpen = false
184+ m.currentQuery = ""
185+ m.completionsStartIndex = 0
186+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
187+ case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
188+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
189+ case msg.String() == "backspace" && m.isCompletionsOpen:
190+ if len(m.currentQuery) > 0 {
191+ m.currentQuery = m.currentQuery[:len(m.currentQuery)-1]
192+ cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
193+ Query: m.currentQuery,
194+ }))
195+ } else {
196+ m.isCompletionsOpen = false
197+ m.currentQuery = ""
198+ m.completionsStartIndex = 0
199+ cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
200+ }
201+ default:
202+ if m.isCompletionsOpen {
203+ m.currentQuery += msg.String()
204+ cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
205+ Query: m.currentQuery,
206+ }))
207+ }
208+ }
209+ if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
210+ m.deleteMode = true
211+ return m, nil
212+ }
213+ if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
214+ m.deleteMode = false
215+ m.attachments = nil
216+ return m, nil
217+ }
218+ rune := msg.Code
219+ if m.deleteMode && unicode.IsDigit(rune) {
220+ num := int(rune - '0')
221+ m.deleteMode = false
222+ if num < 10 && len(m.attachments) > num {
223+ if num == 0 {
224+ m.attachments = m.attachments[num+1:]
225+ } else {
226+ m.attachments = slices.Delete(m.attachments, num, num+1)
227+ }
228+ return m, nil
229+ }
230+ }
231+ if key.Matches(msg, m.keyMap.OpenEditor) {
232+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
233+ return m, util.ReportWarn("Agent is working, please wait...")
234+ }
235+ return m, m.openEditor()
236+ }
237+ if key.Matches(msg, DeleteKeyMaps.Escape) {
238+ m.deleteMode = false
239+ return m, nil
240+ }
241+ // Hanlde Enter key
242+ if m.textarea.Focused() && key.Matches(msg, m.keyMap.Send) {
243+ value := m.textarea.Value()
244+ if len(value) > 0 && value[len(value)-1] == '\\' {
245+ // If the last character is a backslash, remove it and add a newline
246+ m.textarea.SetValue(value[:len(value)-1] + "\n")
247+ return m, nil
248+ } else {
249+ // Otherwise, send the message
250+ return m, m.send()
251+ }
252+ }
253+ }
254+ m.textarea, cmd = m.textarea.Update(msg)
255+ cmds = append(cmds, cmd)
256+ return m, tea.Batch(cmds...)
257+}
258+
259+func (m *editorCmp) View() tea.View {
260+ t := styles.CurrentTheme()
261+ cursor := m.textarea.Cursor()
262+ if cursor != nil {
263+ cursor.X = cursor.X + m.x + 1
264+ cursor.Y = cursor.Y + m.y + 1 // adjust for padding
265+ }
266+ if len(m.attachments) == 0 {
267+ content := t.S().Base.Padding(1).Render(
268+ m.textarea.View(),
269+ )
270+ view := tea.NewView(content)
271+ view.SetCursor(cursor)
272+ return view
273+ }
274+ content := t.S().Base.Padding(0, 1, 1, 1).Render(
275+ lipgloss.JoinVertical(lipgloss.Top,
276+ m.attachmentsContent(),
277+ m.textarea.View(),
278+ ),
279+ )
280+ view := tea.NewView(content)
281+ view.SetCursor(cursor)
282+ return view
283+}
284+
285+func (m *editorCmp) SetSize(width, height int) tea.Cmd {
286+ m.width = width
287+ m.height = height
288+ m.textarea.SetWidth(width - 2) // adjust for padding
289+ m.textarea.SetHeight(height - 2) // adjust for padding
290+ return nil
291+}
292+
293+func (m *editorCmp) GetSize() (int, int) {
294+ return m.textarea.Width(), m.textarea.Height()
295+}
296+
297+func (m *editorCmp) attachmentsContent() string {
298+ var styledAttachments []string
299+ t := styles.CurrentTheme()
300+ attachmentStyles := t.S().Base.
301+ MarginLeft(1).
302+ Background(t.FgMuted).
303+ Foreground(t.FgBase)
304+ for i, attachment := range m.attachments {
305+ var filename string
306+ if len(attachment.FileName) > 10 {
307+ filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
308+ } else {
309+ filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
310+ }
311+ if m.deleteMode {
312+ filename = fmt.Sprintf("%d%s", i, filename)
313+ }
314+ styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
315+ }
316+ content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
317+ return content
318+}
319+
320+func (m *editorCmp) BindingKeys() []key.Binding {
321+ bindings := []key.Binding{}
322+ bindings = append(bindings, layout.KeyMapToSlice(m.keyMap)...)
323+ bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
324+ return bindings
325+}
326+
327+func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
328+ m.x = x
329+ m.y = y
330+ return nil
331+}
332+
333+func (m *editorCmp) startCompletions() tea.Msg {
334+ files, _, _ := fileutil.ListDirectory(".", []string{}, 0)
335+ completionItems := make([]completions.Completion, 0, len(files))
336+ for _, file := range files {
337+ file = strings.TrimPrefix(file, "./")
338+ completionItems = append(completionItems, completions.Completion{
339+ Title: file,
340+ Value: FileCompletionItem{
341+ Path: file,
342+ },
343+ })
344+ }
345+
346+ x := m.textarea.Cursor().X + m.x + 1
347+ y := m.textarea.Cursor().Y + m.y + 1
348+ return completions.OpenCompletionsMsg{
349+ Completions: completionItems,
350+ X: x,
351+ Y: y,
352+ }
353+}
354+
355+// Blur implements Container.
356+func (c *editorCmp) Blur() tea.Cmd {
357+ c.textarea.Blur()
358+ return nil
359+}
360+
361+// Focus implements Container.
362+func (c *editorCmp) Focus() tea.Cmd {
363+ return c.textarea.Focus()
364+}
365+
366+// IsFocused implements Container.
367+func (c *editorCmp) IsFocused() bool {
368+ return c.textarea.Focused()
369+}
370+
371+func NewEditorCmp(app *app.App) util.Model {
372+ t := styles.CurrentTheme()
373+ ta := textarea.New()
374+ ta.SetStyles(t.S().TextArea)
375+ ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
376+ if lineIndex == 0 {
377+ return " > "
378+ }
379+ if focused {
380+ return t.S().Base.Foreground(t.GreenDark).Render("::: ")
381+ } else {
382+ return t.S().Muted.Render("::: ")
383+ }
384+ })
385+ ta.ShowLineNumbers = false
386+ ta.CharLimit = -1
387+ ta.Placeholder = "Tell me more about this project..."
388+ ta.SetVirtualCursor(false)
389+ ta.Focus()
390+
391+ return &editorCmp{
392+ app: app,
393+ textarea: ta,
394+ keyMap: DefaultEditorKeyMap(),
395+ }
396+}
internal/tui/components/chat/editor/keys.gonew file+59
@@ -0,0 +1,59 @@
1+package editor
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type EditorKeyMap struct {
9+ Send key.Binding
10+ OpenEditor key.Binding
11+}
12+
13+func DefaultEditorKeyMap() EditorKeyMap {
14+ return EditorKeyMap{
15+ Send: key.NewBinding(
16+ key.WithKeys("enter"),
17+ key.WithHelp("enter", "send"),
18+ ),
19+ OpenEditor: key.NewBinding(
20+ key.WithKeys("ctrl+e"),
21+ key.WithHelp("ctrl+e", "open editor"),
22+ ),
23+ }
24+}
25+
26+// FullHelp implements help.KeyMap.
27+func (k EditorKeyMap) FullHelp() [][]key.Binding {
28+ m := [][]key.Binding{}
29+ slice := layout.KeyMapToSlice(k)
30+ for i := 0; i < len(slice); i += 4 {
31+ end := min(i+4, len(slice))
32+ m = append(m, slice[i:end])
33+ }
34+ return m
35+}
36+
37+// ShortHelp implements help.KeyMap.
38+func (k EditorKeyMap) ShortHelp() []key.Binding {
39+ return []key.Binding{
40+ k.Send,
41+ k.OpenEditor,
42+ }
43+}
44+
45+// TODO: update this to use the new keymap concepts
46+var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
47+ AttachmentDeleteMode: key.NewBinding(
48+ key.WithKeys("ctrl+r"),
49+ key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
50+ ),
51+ Escape: key.NewBinding(
52+ key.WithKeys("esc"),
53+ key.WithHelp("esc", "cancel delete mode"),
54+ ),
55+ DeleteAllAttachments: key.NewBinding(
56+ key.WithKeys("r"),
57+ key.WithHelp("ctrl+r+r", "delete all attachments"),
58+ ),
59+}
internal/tui/components/chat/list.godeleted−487
@@ -1,487 +0,0 @@
1−package chat
2−
3−import (
4− "context"
5− "fmt"
6− "math"
7−
8− "github.com/charmbracelet/bubbles/key"
9− "github.com/charmbracelet/bubbles/spinner"
10− "github.com/charmbracelet/bubbles/viewport"
11− tea "github.com/charmbracelet/bubbletea"
12− "github.com/charmbracelet/lipgloss"
13− "github.com/opencode-ai/opencode/internal/app"
14− "github.com/opencode-ai/opencode/internal/message"
15− "github.com/opencode-ai/opencode/internal/pubsub"
16− "github.com/opencode-ai/opencode/internal/session"
17− "github.com/opencode-ai/opencode/internal/tui/components/dialog"
18− "github.com/opencode-ai/opencode/internal/tui/styles"
19− "github.com/opencode-ai/opencode/internal/tui/theme"
20− "github.com/opencode-ai/opencode/internal/tui/util"
21−)
22−
23−type cacheItem struct {
24− width int
25− content []uiMessage
26−}
27−type messagesCmp struct {
28− app *app.App
29− width, height int
30− viewport viewport.Model
31− session session.Session
32− messages []message.Message
33− uiMessages []uiMessage
34− currentMsgID string
35− cachedContent map[string]cacheItem
36− spinner spinner.Model
37− rendering bool
38− attachments viewport.Model
39−}
40−type renderFinishedMsg struct{}
41−
42−type MessageKeys struct {
43− PageDown key.Binding
44− PageUp key.Binding
45− HalfPageUp key.Binding
46− HalfPageDown key.Binding
47−}
48−
49−var messageKeys = MessageKeys{
50− PageDown: key.NewBinding(
51− key.WithKeys("pgdown"),
52− key.WithHelp("f/pgdn", "page down"),
53− ),
54− PageUp: key.NewBinding(
55− key.WithKeys("pgup"),
56− key.WithHelp("b/pgup", "page up"),
57− ),
58− HalfPageUp: key.NewBinding(
59− key.WithKeys("ctrl+u"),
60− key.WithHelp("ctrl+u", "½ page up"),
61− ),
62− HalfPageDown: key.NewBinding(
63− key.WithKeys("ctrl+d", "ctrl+d"),
64− key.WithHelp("ctrl+d", "½ page down"),
65− ),
66−}
67−
68−func (m *messagesCmp) Init() tea.Cmd {
69− return tea.Batch(m.viewport.Init(), m.spinner.Tick)
70−}
71−
72−func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73− var cmds []tea.Cmd
74− switch msg := msg.(type) {
75− case dialog.ThemeChangedMsg:
76− m.rerender()
77− return m, nil
78− case SessionSelectedMsg:
79− if msg.ID != m.session.ID {
80− cmd := m.SetSession(msg)
81− return m, cmd
82− }
83− return m, nil
84− case SessionClearedMsg:
85− m.session = session.Session{}
86− m.messages = make([]message.Message, 0)
87− m.currentMsgID = ""
88− m.rendering = false
89− return m, nil
90−
91− case tea.KeyMsg:
92− if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
93− key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
94− u, cmd := m.viewport.Update(msg)
95− m.viewport = u
96− cmds = append(cmds, cmd)
97− }
98−
99− case renderFinishedMsg:
100− m.rendering = false
101− m.viewport.GotoBottom()
102− case pubsub.Event[session.Session]:
103− if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.session.ID {
104− m.session = msg.Payload
105− if m.session.SummaryMessageID == m.currentMsgID {
106− delete(m.cachedContent, m.currentMsgID)
107− m.renderView()
108− }
109− }
110− case pubsub.Event[message.Message]:
111− needsRerender := false
112− if msg.Type == pubsub.CreatedEvent {
113− if msg.Payload.SessionID == m.session.ID {
114−
115− messageExists := false
116− for _, v := range m.messages {
117− if v.ID == msg.Payload.ID {
118− messageExists = true
119− break
120− }
121− }
122−
123− if !messageExists {
124− if len(m.messages) > 0 {
125− lastMsgID := m.messages[len(m.messages)-1].ID
126− delete(m.cachedContent, lastMsgID)
127− }
128−
129− m.messages = append(m.messages, msg.Payload)
130− delete(m.cachedContent, m.currentMsgID)
131− m.currentMsgID = msg.Payload.ID
132− needsRerender = true
133− }
134− }
135− // There are tool calls from the child task
136− for _, v := range m.messages {
137− for _, c := range v.ToolCalls() {
138− if c.ID == msg.Payload.SessionID {
139− delete(m.cachedContent, v.ID)
140− needsRerender = true
141− }
142− }
143− }
144− } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
145− for i, v := range m.messages {
146− if v.ID == msg.Payload.ID {
147− m.messages[i] = msg.Payload
148− delete(m.cachedContent, msg.Payload.ID)
149− needsRerender = true
150− break
151− }
152− }
153− }
154− if needsRerender {
155− m.renderView()
156− if len(m.messages) > 0 {
157− if (msg.Type == pubsub.CreatedEvent) ||
158− (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
159− m.viewport.GotoBottom()
160− }
161− }
162− }
163− }
164−
165− spinner, cmd := m.spinner.Update(msg)
166− m.spinner = spinner
167− cmds = append(cmds, cmd)
168− return m, tea.Batch(cmds...)
169−}
170−
171−func (m *messagesCmp) IsAgentWorking() bool {
172− return m.app.CoderAgent.IsSessionBusy(m.session.ID)
173−}
174−
175−func formatTimeDifference(unixTime1, unixTime2 int64) string {
176− diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
177−
178− if diffSeconds < 60 {
179− return fmt.Sprintf("%.1fs", diffSeconds)
180− }
181−
182− minutes := int(diffSeconds / 60)
183− seconds := int(diffSeconds) % 60
184− return fmt.Sprintf("%dm%ds", minutes, seconds)
185−}
186−
187−func (m *messagesCmp) renderView() {
188− m.uiMessages = make([]uiMessage, 0)
189− pos := 0
190− baseStyle := styles.BaseStyle()
191−
192− if m.width == 0 {
193− return
194− }
195− for inx, msg := range m.messages {
196− switch msg.Role {
197− case message.User:
198− if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
199− m.uiMessages = append(m.uiMessages, cache.content...)
200− continue
201− }
202− userMsg := renderUserMessage(
203− msg,
204− msg.ID == m.currentMsgID,
205− m.width,
206− pos,
207− )
208− m.uiMessages = append(m.uiMessages, userMsg)
209− m.cachedContent[msg.ID] = cacheItem{
210− width: m.width,
211− content: []uiMessage{userMsg},
212− }
213− pos += userMsg.height + 1 // + 1 for spacing
214− case message.Assistant:
215− if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
216− m.uiMessages = append(m.uiMessages, cache.content...)
217− continue
218− }
219− isSummary := m.session.SummaryMessageID == msg.ID
220−
221− assistantMessages := renderAssistantMessage(
222− msg,
223− inx,
224− m.messages,
225− m.app.Messages,
226− m.currentMsgID,
227− isSummary,
228− m.width,
229− pos,
230− )
231− for _, msg := range assistantMessages {
232− m.uiMessages = append(m.uiMessages, msg)
233− pos += msg.height + 1 // + 1 for spacing
234− }
235− m.cachedContent[msg.ID] = cacheItem{
236− width: m.width,
237− content: assistantMessages,
238− }
239− }
240− }
241−
242− messages := make([]string, 0)
243− for _, v := range m.uiMessages {
244− messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content),
245− baseStyle.
246− Width(m.width).
247− Render(
248− "",
249− ),
250− )
251− }
252−
253− m.viewport.SetContent(
254− baseStyle.
255− Width(m.width).
256− Render(
257− lipgloss.JoinVertical(
258− lipgloss.Top,
259− messages...,
260− ),
261− ),
262− )
263−}
264−
265−func (m *messagesCmp) View() string {
266− baseStyle := styles.BaseStyle()
267−
268− if m.rendering {
269− return baseStyle.
270− Width(m.width).
271− Render(
272− lipgloss.JoinVertical(
273− lipgloss.Top,
274− "Loading...",
275− m.working(),
276− m.help(),
277− ),
278− )
279− }
280− if len(m.messages) == 0 {
281− content := baseStyle.
282− Width(m.width).
283− Height(m.height - 1).
284− Render(
285− m.initialScreen(),
286− )
287−
288− return baseStyle.
289− Width(m.width).
290− Render(
291− lipgloss.JoinVertical(
292− lipgloss.Top,
293− content,
294− "",
295− m.help(),
296− ),
297− )
298− }
299−
300− return baseStyle.
301− Width(m.width).
302− Render(
303− lipgloss.JoinVertical(
304− lipgloss.Top,
305− m.viewport.View(),
306− m.working(),
307− m.help(),
308− ),
309− )
310−}
311−
312−func hasToolsWithoutResponse(messages []message.Message) bool {
313− toolCalls := make([]message.ToolCall, 0)
314− toolResults := make([]message.ToolResult, 0)
315− for _, m := range messages {
316− toolCalls = append(toolCalls, m.ToolCalls()...)
317− toolResults = append(toolResults, m.ToolResults()...)
318− }
319−
320− for _, v := range toolCalls {
321− found := false
322− for _, r := range toolResults {
323− if v.ID == r.ToolCallID {
324− found = true
325− break
326− }
327− }
328− if !found && v.Finished {
329− return true
330− }
331− }
332− return false
333−}
334−
335−func hasUnfinishedToolCalls(messages []message.Message) bool {
336− toolCalls := make([]message.ToolCall, 0)
337− for _, m := range messages {
338− toolCalls = append(toolCalls, m.ToolCalls()...)
339− }
340− for _, v := range toolCalls {
341− if !v.Finished {
342− return true
343− }
344− }
345− return false
346−}
347−
348−func (m *messagesCmp) working() string {
349− text := ""
350− if m.IsAgentWorking() && len(m.messages) > 0 {
351− t := theme.CurrentTheme()
352− baseStyle := styles.BaseStyle()
353−
354− task := "Thinking..."
355− lastMessage := m.messages[len(m.messages)-1]
356− if hasToolsWithoutResponse(m.messages) {
357− task = "Waiting for tool response..."
358− } else if hasUnfinishedToolCalls(m.messages) {
359− task = "Building tool call..."
360− } else if !lastMessage.IsFinished() {
361− task = "Generating..."
362− }
363− if task != "" {
364− text += baseStyle.
365− Width(m.width).
366− Foreground(t.Primary()).
367− Bold(true).
368− Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
369− }
370− }
371− return text
372−}
373−
374−func (m *messagesCmp) help() string {
375− t := theme.CurrentTheme()
376− baseStyle := styles.BaseStyle()
377−
378− text := ""
379−
380− if m.app.CoderAgent.IsBusy() {
381− text += lipgloss.JoinHorizontal(
382− lipgloss.Left,
383− baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
384− baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
385− baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"),
386− )
387− } else {
388− text += lipgloss.JoinHorizontal(
389− lipgloss.Left,
390− baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
391− baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
392− baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"),
393− baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"),
394− baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
395− baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"),
396− )
397− }
398− return baseStyle.
399− Width(m.width).
400− Render(text)
401−}
402−
403−func (m *messagesCmp) initialScreen() string {
404− baseStyle := styles.BaseStyle()
405−
406− return baseStyle.Width(m.width).Render(
407− lipgloss.JoinVertical(
408− lipgloss.Top,
409− header(m.width),
410− "",
411− lspsConfigured(m.width),
412− ),
413− )
414−}
415−
416−func (m *messagesCmp) rerender() {
417− for _, msg := range m.messages {
418− delete(m.cachedContent, msg.ID)
419− }
420− m.renderView()
421−}
422−
423−func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
424− if m.width == width && m.height == height {
425− return nil
426− }
427− m.width = width
428− m.height = height
429− m.viewport.Width = width
430− m.viewport.Height = height - 2
431− m.attachments.Width = width + 40
432− m.attachments.Height = 3
433− m.rerender()
434− return nil
435−}
436−
437−func (m *messagesCmp) GetSize() (int, int) {
438− return m.width, m.height
439−}
440−
441−func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
442− if m.session.ID == session.ID {
443− return nil
444− }
445− m.session = session
446− messages, err := m.app.Messages.List(context.Background(), session.ID)
447− if err != nil {
448− return util.ReportError(err)
449− }
450− m.messages = messages
451− if len(m.messages) > 0 {
452− m.currentMsgID = m.messages[len(m.messages)-1].ID
453− }
454− delete(m.cachedContent, m.currentMsgID)
455− m.rendering = true
456− return func() tea.Msg {
457− m.renderView()
458− return renderFinishedMsg{}
459− }
460−}
461−
462−func (m *messagesCmp) BindingKeys() []key.Binding {
463− return []key.Binding{
464− m.viewport.KeyMap.PageDown,
465− m.viewport.KeyMap.PageUp,
466− m.viewport.KeyMap.HalfPageUp,
467− m.viewport.KeyMap.HalfPageDown,
468− }
469−}
470−
471−func NewMessagesCmp(app *app.App) tea.Model {
472− s := spinner.New()
473− s.Spinner = spinner.Pulse
474− vp := viewport.New(0, 0)
475− attachmets := viewport.New(0, 0)
476− vp.KeyMap.PageUp = messageKeys.PageUp
477− vp.KeyMap.PageDown = messageKeys.PageDown
478− vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
479− vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
480− return &messagesCmp{
481− app: app,
482− cachedContent: make(map[string]cacheItem),
483− viewport: vp,
484− spinner: s,
485− attachments: attachmets,
486− }
487−}
internal/tui/components/chat/message.godeleted−659
@@ -1,659 +0,0 @@
1−package chat
2−
3−import (
4− "context"
5− "encoding/json"
6− "fmt"
7− "path/filepath"
8− "strings"
9− "time"
10−
11− "github.com/charmbracelet/lipgloss"
12− "github.com/charmbracelet/x/ansi"
13− "github.com/opencode-ai/opencode/internal/config"
14− "github.com/opencode-ai/opencode/internal/diff"
15− "github.com/opencode-ai/opencode/internal/llm/agent"
16− "github.com/opencode-ai/opencode/internal/llm/models"
17− "github.com/opencode-ai/opencode/internal/llm/tools"
18− "github.com/opencode-ai/opencode/internal/message"
19− "github.com/opencode-ai/opencode/internal/tui/styles"
20− "github.com/opencode-ai/opencode/internal/tui/theme"
21−)
22−
23−type uiMessageType int
24−
25−const (
26− userMessageType uiMessageType = iota
27− assistantMessageType
28− toolMessageType
29−
30− maxResultHeight = 10
31−)
32−
33−type uiMessage struct {
34− ID string
35− messageType uiMessageType
36− position int
37− height int
38− content string
39−}
40−
41−func toMarkdown(content string, focused bool, width int) string {
42− r := styles.GetMarkdownRenderer(width)
43− rendered, _ := r.Render(content)
44− return rendered
45−}
46−
47−func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
48− t := theme.CurrentTheme()
49−
50− style := styles.BaseStyle().
51− Width(width - 1).
52− BorderLeft(true).
53− Foreground(t.TextMuted()).
54− BorderForeground(t.Primary()).
55− BorderStyle(lipgloss.ThickBorder())
56−
57− if isUser {
58− style = style.BorderForeground(t.Secondary())
59− }
60−
61− // Apply markdown formatting and handle background color
62− parts := []string{
63− styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()),
64− }
65−
66− // Remove newline at the end
67− parts[0] = strings.TrimSuffix(parts[0], "\n")
68− if len(info) > 0 {
69− parts = append(parts, info...)
70− }
71−
72− rendered := style.Render(
73− lipgloss.JoinVertical(
74− lipgloss.Left,
75− parts...,
76− ),
77− )
78−
79− return rendered
80−}
81−
82−func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
83− var styledAttachments []string
84− t := theme.CurrentTheme()
85− attachmentStyles := styles.BaseStyle().
86− MarginLeft(1).
87− Background(t.TextMuted()).
88− Foreground(t.Text())
89− for _, attachment := range msg.BinaryContent() {
90− file := filepath.Base(attachment.Path)
91− var filename string
92− if len(file) > 10 {
93− filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
94− } else {
95− filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
96− }
97− styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
98− }
99− content := ""
100− if len(styledAttachments) > 0 {
101− attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
102− content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent)
103− } else {
104− content = renderMessage(msg.Content().String(), true, isFocused, width)
105− }
106− userMsg := uiMessage{
107− ID: msg.ID,
108− messageType: userMessageType,
109− position: position,
110− height: lipgloss.Height(content),
111− content: content,
112− }
113− return userMsg
114−}
115−
116−// Returns multiple uiMessages because of the tool calls
117−func renderAssistantMessage(
118− msg message.Message,
119− msgIndex int,
120− allMessages []message.Message, // we need this to get tool results and the user message
121− messagesService message.Service, // We need this to get the task tool messages
122− focusedUIMessageId string,
123− isSummary bool,
124− width int,
125− position int,
126−) []uiMessage {
127− messages := []uiMessage{}
128− content := msg.Content().String()
129− thinking := msg.IsThinking()
130− thinkingContent := msg.ReasoningContent().Thinking
131− finished := msg.IsFinished()
132− finishData := msg.FinishPart()
133− info := []string{}
134−
135− t := theme.CurrentTheme()
136− baseStyle := styles.BaseStyle()
137−
138− // Add finish info if available
139− if finished {
140− switch finishData.Reason {
141− case message.FinishReasonEndTurn:
142− took := formatTimestampDiff(msg.CreatedAt, finishData.Time)
143− info = append(info, baseStyle.
144− Width(width-1).
145− Foreground(t.TextMuted()).
146− Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took)),
147− )
148− case message.FinishReasonCanceled:
149− info = append(info, baseStyle.
150− Width(width-1).
151− Foreground(t.TextMuted()).
152− Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled")),
153− )
154− case message.FinishReasonError:
155− info = append(info, baseStyle.
156− Width(width-1).
157− Foreground(t.TextMuted()).
158− Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error")),
159− )
160− case message.FinishReasonPermissionDenied:
161− info = append(info, baseStyle.
162− Width(width-1).
163− Foreground(t.TextMuted()).
164− Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied")),
165− )
166− }
167− }
168− if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
169− if content == "" {
170− content = "*Finished without output*"
171− }
172− if isSummary {
173− info = append(info, baseStyle.Width(width-1).Foreground(t.TextMuted()).Render(" (summary)"))
174− }
175−
176− content = renderMessage(content, false, true, width, info...)
177− messages = append(messages, uiMessage{
178− ID: msg.ID,
179− messageType: assistantMessageType,
180− position: position,
181− height: lipgloss.Height(content),
182− content: content,
183− })
184− position += messages[0].height
185− position++ // for the space
186− } else if thinking && thinkingContent != "" {
187− // Render the thinking content
188− content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width)
189− }
190−
191− for i, toolCall := range msg.ToolCalls() {
192− toolCallContent := renderToolMessage(
193− toolCall,
194− allMessages,
195− messagesService,
196− focusedUIMessageId,
197− false,
198− width,
199− i+1,
200− )
201− messages = append(messages, toolCallContent)
202− position += toolCallContent.height
203− position++ // for the space
204− }
205− return messages
206−}
207−
208−func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
209− for _, msg := range futureMessages {
210− for _, result := range msg.ToolResults() {
211− if result.ToolCallID == toolCallID {
212− return &result
213− }
214− }
215− }
216− return nil
217−}
218−
219−func toolName(name string) string {
220− switch name {
221− case agent.AgentToolName:
222− return "Task"
223− case tools.BashToolName:
224− return "Bash"
225− case tools.EditToolName:
226− return "Edit"
227− case tools.FetchToolName:
228− return "Fetch"
229− case tools.GlobToolName:
230− return "Glob"
231− case tools.GrepToolName:
232− return "Grep"
233− case tools.LSToolName:
234− return "List"
235− case tools.SourcegraphToolName:
236− return "Sourcegraph"
237− case tools.ViewToolName:
238− return "View"
239− case tools.WriteToolName:
240− return "Write"
241− case tools.PatchToolName:
242− return "Patch"
243− }
244− return name
245−}
246−
247−func getToolAction(name string) string {
248− switch name {
249− case agent.AgentToolName:
250− return "Preparing prompt..."
251− case tools.BashToolName:
252− return "Building command..."
253− case tools.EditToolName:
254− return "Preparing edit..."
255− case tools.FetchToolName:
256− return "Writing fetch..."
257− case tools.GlobToolName:
258− return "Finding files..."
259− case tools.GrepToolName:
260− return "Searching content..."
261− case tools.LSToolName:
262− return "Listing directory..."
263− case tools.SourcegraphToolName:
264− return "Searching code..."
265− case tools.ViewToolName:
266− return "Reading file..."
267− case tools.WriteToolName:
268− return "Preparing write..."
269− case tools.PatchToolName:
270− return "Preparing patch..."
271− }
272− return "Working..."
273−}
274−
275−// renders params, params[0] (params[1]=params[2] ....)
276−func renderParams(paramsWidth int, params ...string) string {
277− if len(params) == 0 {
278− return ""
279− }
280− mainParam := params[0]
281− if len(mainParam) > paramsWidth {
282− mainParam = mainParam[:paramsWidth-3] + "..."
283− }
284−
285− if len(params) == 1 {
286− return mainParam
287− }
288− otherParams := params[1:]
289− // create pairs of key/value
290− // if odd number of params, the last one is a key without value
291− if len(otherParams)%2 != 0 {
292− otherParams = append(otherParams, "")
293− }
294− parts := make([]string, 0, len(otherParams)/2)
295− for i := 0; i < len(otherParams); i += 2 {
296− key := otherParams[i]
297− value := otherParams[i+1]
298− if value == "" {
299− continue
300− }
301− parts = append(parts, fmt.Sprintf("%s=%s", key, value))
302− }
303−
304− partsRendered := strings.Join(parts, ", ")
305− remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
306− if remainingWidth < 30 {
307− // No space for the params, just show the main
308− return mainParam
309− }
310−
311− if len(parts) > 0 {
312− mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
313− }
314−
315− return ansi.Truncate(mainParam, paramsWidth, "...")
316−}
317−
318−func removeWorkingDirPrefix(path string) string {
319− wd := config.WorkingDirectory()
320− if strings.HasPrefix(path, wd) {
321− path = strings.TrimPrefix(path, wd)
322− }
323− if strings.HasPrefix(path, "/") {
324− path = strings.TrimPrefix(path, "/")
325− }
326− if strings.HasPrefix(path, "./") {
327− path = strings.TrimPrefix(path, "./")
328− }
329− if strings.HasPrefix(path, "../") {
330− path = strings.TrimPrefix(path, "../")
331− }
332− return path
333−}
334−
335−func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
336− params := ""
337− switch toolCall.Name {
338− case agent.AgentToolName:
339− var params agent.AgentParams
340− json.Unmarshal([]byte(toolCall.Input), &params)
341− prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
342− return renderParams(paramWidth, prompt)
343− case tools.BashToolName:
344− var params tools.BashParams
345− json.Unmarshal([]byte(toolCall.Input), &params)
346− command := strings.ReplaceAll(params.Command, "\n", " ")
347− return renderParams(paramWidth, command)
348− case tools.EditToolName:
349− var params tools.EditParams
350− json.Unmarshal([]byte(toolCall.Input), &params)
351− filePath := removeWorkingDirPrefix(params.FilePath)
352− return renderParams(paramWidth, filePath)
353− case tools.FetchToolName:
354− var params tools.FetchParams
355− json.Unmarshal([]byte(toolCall.Input), &params)
356− url := params.URL
357− toolParams := []string{
358− url,
359− }
360− if params.Format != "" {
361− toolParams = append(toolParams, "format", params.Format)
362− }
363− if params.Timeout != 0 {
364− toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
365− }
366− return renderParams(paramWidth, toolParams...)
367− case tools.GlobToolName:
368− var params tools.GlobParams
369− json.Unmarshal([]byte(toolCall.Input), &params)
370− pattern := params.Pattern
371− toolParams := []string{
372− pattern,
373− }
374− if params.Path != "" {
375− toolParams = append(toolParams, "path", params.Path)
376− }
377− return renderParams(paramWidth, toolParams...)
378− case tools.GrepToolName:
379− var params tools.GrepParams
380− json.Unmarshal([]byte(toolCall.Input), &params)
381− pattern := params.Pattern
382− toolParams := []string{
383− pattern,
384− }
385− if params.Path != "" {
386− toolParams = append(toolParams, "path", params.Path)
387− }
388− if params.Include != "" {
389− toolParams = append(toolParams, "include", params.Include)
390− }
391− if params.LiteralText {
392− toolParams = append(toolParams, "literal", "true")
393− }
394− return renderParams(paramWidth, toolParams...)
395− case tools.LSToolName:
396− var params tools.LSParams
397− json.Unmarshal([]byte(toolCall.Input), &params)
398− path := params.Path
399− if path == "" {
400− path = "."
401− }
402− return renderParams(paramWidth, path)
403− case tools.SourcegraphToolName:
404− var params tools.SourcegraphParams
405− json.Unmarshal([]byte(toolCall.Input), &params)
406− return renderParams(paramWidth, params.Query)
407− case tools.ViewToolName:
408− var params tools.ViewParams
409− json.Unmarshal([]byte(toolCall.Input), &params)
410− filePath := removeWorkingDirPrefix(params.FilePath)
411− toolParams := []string{
412− filePath,
413− }
414− if params.Limit != 0 {
415− toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
416− }
417− if params.Offset != 0 {
418− toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
419− }
420− return renderParams(paramWidth, toolParams...)
421− case tools.WriteToolName:
422− var params tools.WriteParams
423− json.Unmarshal([]byte(toolCall.Input), &params)
424− filePath := removeWorkingDirPrefix(params.FilePath)
425− return renderParams(paramWidth, filePath)
426− default:
427− input := strings.ReplaceAll(toolCall.Input, "\n", " ")
428− params = renderParams(paramWidth, input)
429− }
430− return params
431−}
432−
433−func truncateHeight(content string, height int) string {
434− lines := strings.Split(content, "\n")
435− if len(lines) > height {
436− return strings.Join(lines[:height], "\n")
437− }
438− return content
439−}
440−
441−func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
442− t := theme.CurrentTheme()
443− baseStyle := styles.BaseStyle()
444−
445− if response.IsError {
446− errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
447− errContent = ansi.Truncate(errContent, width-1, "...")
448− return baseStyle.
449− Width(width).
450− Foreground(t.Error()).
451− Render(errContent)
452− }
453−
454− resultContent := truncateHeight(response.Content, maxResultHeight)
455− switch toolCall.Name {
456− case agent.AgentToolName:
457− return styles.ForceReplaceBackgroundWithLipgloss(
458− toMarkdown(resultContent, false, width),
459− t.Background(),
460− )
461− case tools.BashToolName:
462− resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
463− return styles.ForceReplaceBackgroundWithLipgloss(
464− toMarkdown(resultContent, true, width),
465− t.Background(),
466− )
467− case tools.EditToolName:
468− metadata := tools.EditResponseMetadata{}
469− json.Unmarshal([]byte(response.Metadata), &metadata)
470− truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
471− formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width))
472− return formattedDiff
473− case tools.FetchToolName:
474− var params tools.FetchParams
475− json.Unmarshal([]byte(toolCall.Input), &params)
476− mdFormat := "markdown"
477− switch params.Format {
478− case "text":
479− mdFormat = "text"
480− case "html":
481− mdFormat = "html"
482− }
483− resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
484− return styles.ForceReplaceBackgroundWithLipgloss(
485− toMarkdown(resultContent, true, width),
486− t.Background(),
487− )
488− case tools.GlobToolName:
489− return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
490− case tools.GrepToolName:
491− return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
492− case tools.LSToolName:
493− return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
494− case tools.SourcegraphToolName:
495− return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
496− case tools.ViewToolName:
497− metadata := tools.ViewResponseMetadata{}
498− json.Unmarshal([]byte(response.Metadata), &metadata)
499− ext := filepath.Ext(metadata.FilePath)
500− if ext == "" {
501− ext = ""
502− } else {
503− ext = strings.ToLower(ext[1:])
504− }
505− resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
506− return styles.ForceReplaceBackgroundWithLipgloss(
507− toMarkdown(resultContent, true, width),
508− t.Background(),
509− )
510− case tools.WriteToolName:
511− params := tools.WriteParams{}
512− json.Unmarshal([]byte(toolCall.Input), &params)
513− metadata := tools.WriteResponseMetadata{}
514− json.Unmarshal([]byte(response.Metadata), &metadata)
515− ext := filepath.Ext(params.FilePath)
516− if ext == "" {
517− ext = ""
518− } else {
519− ext = strings.ToLower(ext[1:])
520− }
521− resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
522− return styles.ForceReplaceBackgroundWithLipgloss(
523− toMarkdown(resultContent, true, width),
524− t.Background(),
525− )
526− default:
527− resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
528− return styles.ForceReplaceBackgroundWithLipgloss(
529− toMarkdown(resultContent, true, width),
530− t.Background(),
531− )
532− }
533−}
534−
535−func renderToolMessage(
536− toolCall message.ToolCall,
537− allMessages []message.Message,
538− messagesService message.Service,
539− focusedUIMessageId string,
540− nested bool,
541− width int,
542− position int,
543−) uiMessage {
544− if nested {
545− width = width - 3
546− }
547−
548− t := theme.CurrentTheme()
549− baseStyle := styles.BaseStyle()
550−
551− style := baseStyle.
552− Width(width - 1).
553− BorderLeft(true).
554− BorderStyle(lipgloss.ThickBorder()).
555− PaddingLeft(1).
556− BorderForeground(t.TextMuted())
557−
558− response := findToolResponse(toolCall.ID, allMessages)
559− toolNameText := baseStyle.Foreground(t.TextMuted()).
560− Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
561−
562− if !toolCall.Finished {
563− // Get a brief description of what the tool is doing
564− toolAction := getToolAction(toolCall.Name)
565−
566− progressText := baseStyle.
567− Width(width - 2 - lipgloss.Width(toolNameText)).
568− Foreground(t.TextMuted()).
569− Render(fmt.Sprintf("%s", toolAction))
570−
571− content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
572− toolMsg := uiMessage{
573− messageType: toolMessageType,
574− position: position,
575− height: lipgloss.Height(content),
576− content: content,
577− }
578− return toolMsg
579− }
580−
581− params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall)
582− responseContent := ""
583− if response != nil {
584− responseContent = renderToolResponse(toolCall, *response, width-2)
585− responseContent = strings.TrimSuffix(responseContent, "\n")
586− } else {
587− responseContent = baseStyle.
588− Italic(true).
589− Width(width - 2).
590− Foreground(t.TextMuted()).
591− Render("Waiting for response...")
592− }
593−
594− parts := []string{}
595− if !nested {
596− formattedParams := baseStyle.
597− Width(width - 2 - lipgloss.Width(toolNameText)).
598− Foreground(t.TextMuted()).
599− Render(params)
600−
601− parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
602− } else {
603− prefix := baseStyle.
604− Foreground(t.TextMuted()).
605− Render(" └ ")
606− formattedParams := baseStyle.
607− Width(width - 2 - lipgloss.Width(toolNameText)).
608− Foreground(t.TextMuted()).
609− Render(params)
610− parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
611− }
612−
613− if toolCall.Name == agent.AgentToolName {
614− taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
615− toolCalls := []message.ToolCall{}
616− for _, v := range taskMessages {
617− toolCalls = append(toolCalls, v.ToolCalls()...)
618− }
619− for _, call := range toolCalls {
620− rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
621− parts = append(parts, rendered.content)
622− }
623− }
624− if responseContent != "" && !nested {
625− parts = append(parts, responseContent)
626− }
627−
628− content := style.Render(
629− lipgloss.JoinVertical(
630− lipgloss.Left,
631− parts...,
632− ),
633− )
634− if nested {
635− content = lipgloss.JoinVertical(
636− lipgloss.Left,
637− parts...,
638− )
639− }
640− toolMsg := uiMessage{
641− messageType: toolMessageType,
642− position: position,
643− height: lipgloss.Height(content),
644− content: content,
645− }
646− return toolMsg
647−}
648−
649−// Helper function to format the time difference between two Unix timestamps
650−func formatTimestampDiff(start, end int64) string {
651− diffSeconds := float64(end-start) / 1000.0 // Convert to seconds
652− if diffSeconds < 1 {
653− return fmt.Sprintf("%dms", int(diffSeconds*1000))
654− }
655− if diffSeconds < 60 {
656− return fmt.Sprintf("%.1fs", diffSeconds)
657− }
658− return fmt.Sprintf("%.1fm", diffSeconds/60)
659−}
internal/tui/components/chat/messages/messages.gonew file+289
@@ -0,0 +1,289 @@
1+package messages
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+ "time"
8+
9+ "github.com/charmbracelet/bubbles/v2/spinner"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/llm/models"
12+ "github.com/charmbracelet/lipgloss/v2"
13+
14+ "github.com/charmbracelet/crush/internal/message"
15+ "github.com/charmbracelet/crush/internal/tui/components/anim"
16+ "github.com/charmbracelet/crush/internal/tui/components/core"
17+ "github.com/charmbracelet/crush/internal/tui/layout"
18+ "github.com/charmbracelet/crush/internal/tui/styles"
19+ "github.com/charmbracelet/crush/internal/tui/util"
20+)
21+
22+// MessageCmp defines the interface for message components in the chat interface.
23+// It combines standard UI model interfaces with message-specific functionality.
24+type MessageCmp interface {
25+ util.Model // Basic Bubble Tea model interface
26+ layout.Sizeable // Width/height management
27+ layout.Focusable // Focus state management
28+ GetMessage() message.Message // Access to underlying message data
29+ Spinning() bool // Animation state for loading messages
30+}
31+
32+// messageCmp implements the MessageCmp interface for displaying chat messages.
33+// It handles rendering of user and assistant messages with proper styling,
34+// animations, and state management.
35+type messageCmp struct {
36+ width int // Component width for text wrapping
37+ focused bool // Focus state for border styling
38+
39+ // Core message data and state
40+ message message.Message // The underlying message content
41+ spinning bool // Whether to show loading animation
42+ anim util.Model // Animation component for loading states
43+ lastUserMessageTime time.Time // Used for calculating response duration
44+}
45+
46+// MessageOption provides functional options for configuring message components
47+type MessageOption func(*messageCmp)
48+
49+// WithLastUserMessageTime sets the timestamp of the last user message
50+// for calculating assistant response duration
51+func WithLastUserMessageTime(t time.Time) MessageOption {
52+ return func(m *messageCmp) {
53+ m.lastUserMessageTime = t
54+ }
55+}
56+
57+// NewMessageCmp creates a new message component with the given message and options
58+func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
59+ m := &messageCmp{
60+ message: msg,
61+ anim: anim.New(15, ""),
62+ }
63+ for _, opt := range opts {
64+ opt(m)
65+ }
66+ return m
67+}
68+
69+// Init initializes the message component and starts animations if needed.
70+// Returns a command to start the animation for spinning messages.
71+func (m *messageCmp) Init() tea.Cmd {
72+ m.spinning = m.shouldSpin()
73+ if m.spinning {
74+ return m.anim.Init()
75+ }
76+ return nil
77+}
78+
79+// Update handles incoming messages and updates the component state.
80+// Manages animation updates for spinning messages and stops animation when appropriate.
81+func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82+ switch msg := msg.(type) {
83+ case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
84+ m.spinning = m.shouldSpin()
85+ if m.spinning {
86+ u, cmd := m.anim.Update(msg)
87+ m.anim = u.(util.Model)
88+ return m, cmd
89+ }
90+ }
91+ return m, nil
92+}
93+
94+// View renders the message component based on its current state.
95+// Returns different views for spinning, user, and assistant messages.
96+func (m *messageCmp) View() tea.View {
97+ if m.spinning {
98+ return tea.NewView(m.style().PaddingLeft(1).Render(m.anim.View().String()))
99+ }
100+ if m.message.ID != "" {
101+ // this is a user or assistant message
102+ switch m.message.Role {
103+ case message.User:
104+ return tea.NewView(m.renderUserMessage())
105+ default:
106+ return tea.NewView(m.renderAssistantMessage())
107+ }
108+ }
109+ return tea.NewView(m.style().Render("No message content"))
110+}
111+
112+// GetMessage returns the underlying message data
113+func (m *messageCmp) GetMessage() message.Message {
114+ return m.message
115+}
116+
117+// textWidth calculates the available width for text content,
118+// accounting for borders and padding
119+func (m *messageCmp) textWidth() int {
120+ return m.width - 2 // take into account the border and/or padding
121+}
122+
123+// style returns the lipgloss style for the message component.
124+// Applies different border colors and styles based on message role and focus state.
125+func (msg *messageCmp) style() lipgloss.Style {
126+ t := styles.CurrentTheme()
127+ borderStyle := lipgloss.NormalBorder()
128+ if msg.focused {
129+ borderStyle = lipgloss.ThickBorder()
130+ }
131+
132+ style := t.S().Text
133+ if msg.message.Role == message.User {
134+ style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
135+ } else {
136+ if msg.focused {
137+ style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
138+ } else {
139+ style = style.PaddingLeft(2)
140+ }
141+ }
142+ return style
143+}
144+
145+// renderAssistantMessage renders assistant messages with optional footer information.
146+// Shows model name, response time, and finish reason when the message is complete.
147+func (m *messageCmp) renderAssistantMessage() string {
148+ t := styles.CurrentTheme()
149+ parts := []string{
150+ m.markdownContent(),
151+ }
152+
153+ finished := m.message.IsFinished()
154+ finishData := m.message.FinishPart()
155+ // Only show the footer if the message is not a tool call
156+ if finished && finishData.Reason != message.FinishReasonToolUse {
157+ infoMsg := ""
158+ switch finishData.Reason {
159+ case message.FinishReasonEndTurn:
160+ finishTime := time.Unix(finishData.Time, 0)
161+ duration := finishTime.Sub(m.lastUserMessageTime)
162+ infoMsg = duration.String()
163+ case message.FinishReasonCanceled:
164+ infoMsg = "canceled"
165+ case message.FinishReasonError:
166+ infoMsg = "error"
167+ case message.FinishReasonPermissionDenied:
168+ infoMsg = "permission denied"
169+ }
170+ assistant := t.S().Muted.Render(fmt.Sprintf("⬡ %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
171+ parts = append(parts, core.Section(assistant, m.textWidth()))
172+ }
173+
174+ joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
175+ return m.style().Render(joined)
176+}
177+
178+// renderUserMessage renders user messages with file attachments.
179+// Displays message content and any attached files with appropriate icons.
180+func (m *messageCmp) renderUserMessage() string {
181+ t := styles.CurrentTheme()
182+ parts := []string{
183+ m.markdownContent(),
184+ }
185+ attachmentStyles := t.S().Text.
186+ MarginLeft(1).
187+ Background(t.BgSubtle)
188+ attachments := []string{}
189+ for _, attachment := range m.message.BinaryContent() {
190+ file := filepath.Base(attachment.Path)
191+ var filename string
192+ if len(file) > 10 {
193+ filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
194+ } else {
195+ filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
196+ }
197+ attachments = append(attachments, attachmentStyles.Render(filename))
198+ }
199+ if len(attachments) > 0 {
200+ parts = append(parts, "", strings.Join(attachments, ""))
201+ }
202+ joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
203+ return m.style().MarginBottom(1).Render(joined)
204+}
205+
206+// toMarkdown converts text content to rendered markdown using the configured renderer
207+func (m *messageCmp) toMarkdown(content string) string {
208+ r := styles.GetMarkdownRenderer(m.textWidth())
209+ rendered, _ := r.Render(content)
210+ return strings.TrimSuffix(rendered, "\n")
211+}
212+
213+// markdownContent processes the message content and handles special states.
214+// Returns appropriate content for thinking, finished, and error states.
215+func (m *messageCmp) markdownContent() string {
216+ content := m.message.Content().String()
217+ if m.message.Role == message.Assistant {
218+ thinking := m.message.IsThinking()
219+ finished := m.message.IsFinished()
220+ finishedData := m.message.FinishPart()
221+ if thinking {
222+ // Handle the thinking state
223+ // TODO: maybe add the thinking content if available later.
224+ content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
225+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
226+ // Sometimes the LLMs respond with no content when they think the previous tool result
227+ // provides the requested question
228+ content = "*Finished without output*"
229+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
230+ content = "*Canceled*"
231+ }
232+ }
233+ return m.toMarkdown(content)
234+}
235+
236+// shouldSpin determines whether the message should show a loading animation.
237+// Only assistant messages without content that aren't finished should spin.
238+func (m *messageCmp) shouldSpin() bool {
239+ if m.message.Role != message.Assistant {
240+ return false
241+ }
242+
243+ if m.message.IsFinished() {
244+ return false
245+ }
246+
247+ if m.message.Content().Text != "" {
248+ return false
249+ }
250+ return true
251+}
252+
253+// Focus management methods
254+
255+// Blur removes focus from the message component
256+func (m *messageCmp) Blur() tea.Cmd {
257+ m.focused = false
258+ return nil
259+}
260+
261+// Focus sets focus on the message component
262+func (m *messageCmp) Focus() tea.Cmd {
263+ m.focused = true
264+ return nil
265+}
266+
267+// IsFocused returns whether the message component is currently focused
268+func (m *messageCmp) IsFocused() bool {
269+ return m.focused
270+}
271+
272+// Size management methods
273+
274+// GetSize returns the current dimensions of the message component
275+func (m *messageCmp) GetSize() (int, int) {
276+ return m.width, 0
277+}
278+
279+// SetSize updates the width of the message component for text wrapping
280+func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
281+ // For better readability, we limit the width to a maximum of 120 characters
282+ m.width = min(width, 120)
283+ return nil
284+}
285+
286+// Spinning returns whether the message is currently showing a loading animation
287+func (m *messageCmp) Spinning() bool {
288+ return m.spinning
289+}
internal/tui/components/chat/messages/renderer.gonew file+719
@@ -0,0 +1,719 @@
1+package messages
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "github.com/charmbracelet/crush/internal/diff"
10+ "github.com/charmbracelet/crush/internal/fileutil"
11+ "github.com/charmbracelet/crush/internal/highlight"
12+ "github.com/charmbracelet/crush/internal/llm/agent"
13+ "github.com/charmbracelet/crush/internal/llm/tools"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
15+ "github.com/charmbracelet/lipgloss/v2"
16+ "github.com/charmbracelet/lipgloss/v2/tree"
17+ "github.com/charmbracelet/x/ansi"
18+)
19+
20+// responseContextHeight limits the number of lines displayed in tool output
21+const responseContextHeight = 10
22+
23+// renderer defines the interface for tool-specific rendering implementations
24+type renderer interface {
25+ // Render returns the complete (already styled) tool‑call view, not
26+ // including the outer border.
27+ Render(v *toolCallCmp) string
28+}
29+
30+// rendererFactory creates new renderer instances
31+type rendererFactory func() renderer
32+
33+// renderRegistry manages the mapping of tool names to their renderers
34+type renderRegistry map[string]rendererFactory
35+
36+// register adds a new renderer factory to the registry
37+func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
38+
39+// lookup retrieves a renderer for the given tool name, falling back to generic renderer
40+func (rr renderRegistry) lookup(name string) renderer {
41+ if f, ok := rr[name]; ok {
42+ return f()
43+ }
44+ return genericRenderer{} // sensible fallback
45+}
46+
47+// registry holds all registered tool renderers
48+var registry = renderRegistry{}
49+
50+// baseRenderer provides common functionality for all tool renderers
51+type baseRenderer struct{}
52+
53+// paramBuilder helps construct parameter lists for tool headers
54+type paramBuilder struct {
55+ args []string
56+}
57+
58+// newParamBuilder creates a new parameter builder
59+func newParamBuilder() *paramBuilder {
60+ return &paramBuilder{args: make([]string, 0)}
61+}
62+
63+// addMain adds the main parameter (first argument)
64+func (pb *paramBuilder) addMain(value string) *paramBuilder {
65+ if value != "" {
66+ pb.args = append(pb.args, value)
67+ }
68+ return pb
69+}
70+
71+// addKeyValue adds a key-value pair parameter
72+func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
73+ if value != "" {
74+ pb.args = append(pb.args, key, value)
75+ }
76+ return pb
77+}
78+
79+// addFlag adds a boolean flag parameter
80+func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
81+ if value {
82+ pb.args = append(pb.args, key, "true")
83+ }
84+ return pb
85+}
86+
87+// build returns the final parameter list
88+func (pb *paramBuilder) build() []string {
89+ return pb.args
90+}
91+
92+// renderWithParams provides a common rendering pattern for tools with parameters
93+func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
94+ width := v.textWidth()
95+ if v.isNested {
96+ width -= 4 // Adjust for nested tool call indentation
97+ }
98+ header := br.makeHeader(v, toolName, width, args...)
99+ if v.isNested {
100+ return v.style().Render(header)
101+ }
102+ if res, done := earlyState(header, v); done {
103+ return res
104+ }
105+ body := contentRenderer()
106+ return joinHeaderBody(header, body)
107+}
108+
109+// unmarshalParams safely unmarshal JSON parameters
110+func (br baseRenderer) unmarshalParams(input string, target any) error {
111+ return json.Unmarshal([]byte(input), target)
112+}
113+
114+// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
115+func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
116+ t := styles.CurrentTheme()
117+ icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
118+ if v.result.ToolCallID != "" {
119+ if v.result.IsError {
120+ icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
121+ } else {
122+ icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
123+ }
124+ } else if v.cancelled {
125+ icon = t.S().Muted.Render(styles.ToolPending)
126+ }
127+ tool = t.S().Base.Foreground(t.Blue).Render(tool)
128+ prefix := fmt.Sprintf("%s %s: ", icon, tool)
129+ return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
130+}
131+
132+// renderError provides consistent error rendering
133+func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
134+ t := styles.CurrentTheme()
135+ header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
136+ message = t.S().Error.Render(v.fit(message, v.textWidth()-2)) // -2 for padding
137+ return joinHeaderBody(header, message)
138+}
139+
140+// Register tool renderers
141+func init() {
142+ registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
143+ registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
144+ registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
145+ registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
146+ registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} })
147+ registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
148+ registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
149+ registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
150+ registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
151+ registry.register(tools.PatchToolName, func() renderer { return patchRenderer{} })
152+ registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
153+ registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
154+}
155+
156+// -----------------------------------------------------------------------------
157+// Generic renderer
158+// -----------------------------------------------------------------------------
159+
160+// genericRenderer handles unknown tool types with basic parameter display
161+type genericRenderer struct {
162+ baseRenderer
163+}
164+
165+// Render displays the tool call with its raw input and plain content output
166+func (gr genericRenderer) Render(v *toolCallCmp) string {
167+ return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
168+ return renderPlainContent(v, v.result.Content)
169+ })
170+}
171+
172+// -----------------------------------------------------------------------------
173+// Bash renderer
174+// -----------------------------------------------------------------------------
175+
176+// bashRenderer handles bash command execution display
177+type bashRenderer struct {
178+ baseRenderer
179+}
180+
181+// Render displays the bash command with sanitized newlines and plain output
182+func (br bashRenderer) Render(v *toolCallCmp) string {
183+ var params tools.BashParams
184+ if err := br.unmarshalParams(v.call.Input, &params); err != nil {
185+ return br.renderError(v, "Invalid bash parameters")
186+ }
187+
188+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
189+ args := newParamBuilder().addMain(cmd).build()
190+
191+ return br.renderWithParams(v, "Bash", args, func() string {
192+ return renderPlainContent(v, v.result.Content)
193+ })
194+}
195+
196+// -----------------------------------------------------------------------------
197+// View renderer
198+// -----------------------------------------------------------------------------
199+
200+// viewRenderer handles file viewing with syntax highlighting and line numbers
201+type viewRenderer struct {
202+ baseRenderer
203+}
204+
205+// Render displays file content with optional limit and offset parameters
206+func (vr viewRenderer) Render(v *toolCallCmp) string {
207+ var params tools.ViewParams
208+ if err := vr.unmarshalParams(v.call.Input, &params); err != nil {
209+ return vr.renderError(v, "Invalid view parameters")
210+ }
211+
212+ file := fileutil.PrettyPath(params.FilePath)
213+ args := newParamBuilder().
214+ addMain(file).
215+ addKeyValue("limit", formatNonZero(params.Limit)).
216+ addKeyValue("offset", formatNonZero(params.Offset)).
217+ build()
218+
219+ return vr.renderWithParams(v, "View", args, func() string {
220+ var meta tools.ViewResponseMetadata
221+ if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
222+ return renderPlainContent(v, v.result.Content)
223+ }
224+ return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
225+ })
226+}
227+
228+// formatNonZero returns string representation of non-zero integers, empty string for zero
229+func formatNonZero(value int) string {
230+ if value == 0 {
231+ return ""
232+ }
233+ return fmt.Sprintf("%d", value)
234+}
235+
236+// -----------------------------------------------------------------------------
237+// Edit renderer
238+// -----------------------------------------------------------------------------
239+
240+// editRenderer handles file editing with diff visualization
241+type editRenderer struct {
242+ baseRenderer
243+}
244+
245+// Render displays the edited file with a formatted diff of changes
246+func (er editRenderer) Render(v *toolCallCmp) string {
247+ var params tools.EditParams
248+ if err := er.unmarshalParams(v.call.Input, &params); err != nil {
249+ return er.renderError(v, "Invalid edit parameters")
250+ }
251+
252+ file := fileutil.PrettyPath(params.FilePath)
253+ args := newParamBuilder().addMain(file).build()
254+
255+ return er.renderWithParams(v, "Edit", args, func() string {
256+ var meta tools.EditResponseMetadata
257+ if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
258+ return renderPlainContent(v, v.result.Content)
259+ }
260+
261+ trunc := truncateHeight(meta.Diff, responseContextHeight)
262+ diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()-2))
263+ return diffView
264+ })
265+}
266+
267+// -----------------------------------------------------------------------------
268+// Write renderer
269+// -----------------------------------------------------------------------------
270+
271+// writeRenderer handles file writing with syntax-highlighted content preview
272+type writeRenderer struct {
273+ baseRenderer
274+}
275+
276+// Render displays the file being written with syntax highlighting
277+func (wr writeRenderer) Render(v *toolCallCmp) string {
278+ var params tools.WriteParams
279+ if err := wr.unmarshalParams(v.call.Input, &params); err != nil {
280+ return wr.renderError(v, "Invalid write parameters")
281+ }
282+
283+ file := fileutil.PrettyPath(params.FilePath)
284+ args := newParamBuilder().addMain(file).build()
285+
286+ return wr.renderWithParams(v, "Write", args, func() string {
287+ return renderCodeContent(v, file, params.Content, 0)
288+ })
289+}
290+
291+// -----------------------------------------------------------------------------
292+// Fetch renderer
293+// -----------------------------------------------------------------------------
294+
295+// fetchRenderer handles URL fetching with format-specific content display
296+type fetchRenderer struct {
297+ baseRenderer
298+}
299+
300+// Render displays the fetched URL with format and timeout parameters
301+func (fr fetchRenderer) Render(v *toolCallCmp) string {
302+ var params tools.FetchParams
303+ if err := fr.unmarshalParams(v.call.Input, &params); err != nil {
304+ return fr.renderError(v, "Invalid fetch parameters")
305+ }
306+
307+ args := newParamBuilder().
308+ addMain(params.URL).
309+ addKeyValue("format", params.Format).
310+ addKeyValue("timeout", formatTimeout(params.Timeout)).
311+ build()
312+
313+ return fr.renderWithParams(v, "Fetch", args, func() string {
314+ file := fr.getFileExtension(params.Format)
315+ return renderCodeContent(v, file, v.result.Content, 0)
316+ })
317+}
318+
319+// getFileExtension returns appropriate file extension for syntax highlighting
320+func (fr fetchRenderer) getFileExtension(format string) string {
321+ switch format {
322+ case "text":
323+ return "fetch.txt"
324+ case "html":
325+ return "fetch.html"
326+ default:
327+ return "fetch.md"
328+ }
329+}
330+
331+// formatTimeout converts timeout seconds to duration string
332+func formatTimeout(timeout int) string {
333+ if timeout == 0 {
334+ return ""
335+ }
336+ return (time.Duration(timeout) * time.Second).String()
337+}
338+
339+// -----------------------------------------------------------------------------
340+// Glob renderer
341+// -----------------------------------------------------------------------------
342+
343+// globRenderer handles file pattern matching with path filtering
344+type globRenderer struct {
345+ baseRenderer
346+}
347+
348+// Render displays the glob pattern with optional path parameter
349+func (gr globRenderer) Render(v *toolCallCmp) string {
350+ var params tools.GlobParams
351+ if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
352+ return gr.renderError(v, "Invalid glob parameters")
353+ }
354+
355+ args := newParamBuilder().
356+ addMain(params.Pattern).
357+ addKeyValue("path", params.Path).
358+ build()
359+
360+ return gr.renderWithParams(v, "Glob", args, func() string {
361+ return renderPlainContent(v, v.result.Content)
362+ })
363+}
364+
365+// -----------------------------------------------------------------------------
366+// Grep renderer
367+// -----------------------------------------------------------------------------
368+
369+// grepRenderer handles content searching with pattern matching options
370+type grepRenderer struct {
371+ baseRenderer
372+}
373+
374+// Render displays the search pattern with path, include, and literal text options
375+func (gr grepRenderer) Render(v *toolCallCmp) string {
376+ var params tools.GrepParams
377+ if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
378+ return gr.renderError(v, "Invalid grep parameters")
379+ }
380+
381+ args := newParamBuilder().
382+ addMain(params.Pattern).
383+ addKeyValue("path", params.Path).
384+ addKeyValue("include", params.Include).
385+ addFlag("literal", params.LiteralText).
386+ build()
387+
388+ return gr.renderWithParams(v, "Grep", args, func() string {
389+ return renderPlainContent(v, v.result.Content)
390+ })
391+}
392+
393+// -----------------------------------------------------------------------------
394+// LS renderer
395+// -----------------------------------------------------------------------------
396+
397+// lsRenderer handles directory listing with default path handling
398+type lsRenderer struct {
399+ baseRenderer
400+}
401+
402+// Render displays the directory path, defaulting to current directory
403+func (lr lsRenderer) Render(v *toolCallCmp) string {
404+ var params tools.LSParams
405+ if err := lr.unmarshalParams(v.call.Input, &params); err != nil {
406+ return lr.renderError(v, "Invalid ls parameters")
407+ }
408+
409+ path := params.Path
410+ if path == "" {
411+ path = "."
412+ }
413+ path = fileutil.PrettyPath(path)
414+
415+ args := newParamBuilder().addMain(path).build()
416+
417+ return lr.renderWithParams(v, "List", args, func() string {
418+ return renderPlainContent(v, v.result.Content)
419+ })
420+}
421+
422+// -----------------------------------------------------------------------------
423+// Sourcegraph renderer
424+// -----------------------------------------------------------------------------
425+
426+// sourcegraphRenderer handles code search with count and context options
427+type sourcegraphRenderer struct {
428+ baseRenderer
429+}
430+
431+// Render displays the search query with optional count and context window parameters
432+func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
433+ var params tools.SourcegraphParams
434+ if err := sr.unmarshalParams(v.call.Input, &params); err != nil {
435+ return sr.renderError(v, "Invalid sourcegraph parameters")
436+ }
437+
438+ args := newParamBuilder().
439+ addMain(params.Query).
440+ addKeyValue("count", formatNonZero(params.Count)).
441+ addKeyValue("context", formatNonZero(params.ContextWindow)).
442+ build()
443+
444+ return sr.renderWithParams(v, "Sourcegraph", args, func() string {
445+ return renderPlainContent(v, v.result.Content)
446+ })
447+}
448+
449+// -----------------------------------------------------------------------------
450+// Patch renderer
451+// -----------------------------------------------------------------------------
452+
453+// patchRenderer handles multi-file patches with change summaries
454+type patchRenderer struct {
455+ baseRenderer
456+}
457+
458+// Render displays patch summary with file count and change statistics
459+func (pr patchRenderer) Render(v *toolCallCmp) string {
460+ var params tools.PatchParams
461+ if err := pr.unmarshalParams(v.call.Input, &params); err != nil {
462+ return pr.renderError(v, "Invalid patch parameters")
463+ }
464+
465+ args := newParamBuilder().addMain("multiple files").build()
466+
467+ return pr.renderWithParams(v, "Patch", args, func() string {
468+ var meta tools.PatchResponseMetadata
469+ if err := pr.unmarshalParams(v.result.Metadata, &meta); err != nil {
470+ return renderPlainContent(v, v.result.Content)
471+ }
472+
473+ summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
474+ len(meta.FilesChanged), meta.Additions, meta.Removals)
475+ filesList := strings.Join(meta.FilesChanged, "\n")
476+
477+ return renderPlainContent(v, summary+"\n\n"+filesList)
478+ })
479+}
480+
481+// -----------------------------------------------------------------------------
482+// Diagnostics renderer
483+// -----------------------------------------------------------------------------
484+
485+// diagnosticsRenderer handles project-wide diagnostic information
486+type diagnosticsRenderer struct {
487+ baseRenderer
488+}
489+
490+// Render displays project diagnostics with plain content formatting
491+func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
492+ args := newParamBuilder().addMain("project").build()
493+
494+ return dr.renderWithParams(v, "Diagnostics", args, func() string {
495+ return renderPlainContent(v, v.result.Content)
496+ })
497+}
498+
499+// -----------------------------------------------------------------------------
500+// Task renderer
501+// -----------------------------------------------------------------------------
502+
503+// agentRenderer handles project-wide diagnostic information
504+type agentRenderer struct {
505+ baseRenderer
506+}
507+
508+// Render displays agent task parameters and result content
509+func (tr agentRenderer) Render(v *toolCallCmp) string {
510+ var params agent.AgentParams
511+ if err := tr.unmarshalParams(v.call.Input, &params); err != nil {
512+ return tr.renderError(v, "Invalid task parameters")
513+ }
514+ prompt := params.Prompt
515+ prompt = strings.ReplaceAll(prompt, "\n", " ")
516+ args := newParamBuilder().addMain(prompt).build()
517+
518+ header := tr.makeHeader(v, "Task", v.textWidth(), args...)
519+ t := tree.Root(header)
520+
521+ for _, call := range v.nestedToolCalls {
522+ t.Child(call.View())
523+ }
524+
525+ parts := []string{
526+ t.Enumerator(tree.RoundedEnumerator).String(),
527+ }
528+ if v.result.ToolCallID == "" {
529+ v.spinning = true
530+ parts = append(parts, v.anim.View().String())
531+ } else {
532+ v.spinning = false
533+ }
534+
535+ header = lipgloss.JoinVertical(
536+ lipgloss.Left,
537+ parts...,
538+ )
539+
540+ if v.result.ToolCallID == "" {
541+ return header
542+ }
543+
544+ body := renderPlainContent(v, v.result.Content)
545+ return joinHeaderBody(header, body)
546+}
547+
548+// renderParamList renders params, params[0] (params[1]=params[2] ....)
549+func renderParamList(paramsWidth int, params ...string) string {
550+ if len(params) == 0 {
551+ return ""
552+ }
553+ mainParam := params[0]
554+ if len(mainParam) > paramsWidth {
555+ mainParam = mainParam[:paramsWidth-3] + "..."
556+ }
557+
558+ if len(params) == 1 {
559+ return mainParam
560+ }
561+ otherParams := params[1:]
562+ // create pairs of key/value
563+ // if odd number of params, the last one is a key without value
564+ if len(otherParams)%2 != 0 {
565+ otherParams = append(otherParams, "")
566+ }
567+ parts := make([]string, 0, len(otherParams)/2)
568+ for i := 0; i < len(otherParams); i += 2 {
569+ key := otherParams[i]
570+ value := otherParams[i+1]
571+ if value == "" {
572+ continue
573+ }
574+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
575+ }
576+
577+ partsRendered := strings.Join(parts, ", ")
578+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
579+ if remainingWidth < 30 {
580+ // No space for the params, just show the main
581+ return mainParam
582+ }
583+
584+ if len(parts) > 0 {
585+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
586+ }
587+
588+ return ansi.Truncate(mainParam, paramsWidth, "...")
589+}
590+
591+// earlyState returns immediately‑rendered error/cancelled/ongoing states.
592+func earlyState(header string, v *toolCallCmp) (string, bool) {
593+ t := styles.CurrentTheme()
594+ message := ""
595+ switch {
596+ case v.result.IsError:
597+ message = v.renderToolError()
598+ case v.cancelled:
599+ message = "Cancelled"
600+ case v.result.ToolCallID == "":
601+ message = "Waiting for tool to start..."
602+ default:
603+ return "", false
604+ }
605+
606+ message = t.S().Base.PaddingLeft(2).Render(message)
607+ return lipgloss.JoinVertical(lipgloss.Left, header, message), true
608+}
609+
610+func joinHeaderBody(header, body string) string {
611+ t := styles.CurrentTheme()
612+ body = t.S().Base.PaddingLeft(2).Render(body)
613+ return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
614+}
615+
616+func renderPlainContent(v *toolCallCmp, content string) string {
617+ t := styles.CurrentTheme()
618+ content = strings.TrimSpace(content)
619+ lines := strings.Split(content, "\n")
620+
621+ width := v.textWidth() - 2 // -2 for left padding
622+ var out []string
623+ for i, ln := range lines {
624+ if i >= responseContextHeight {
625+ break
626+ }
627+ ln = " " + ln // left padding
628+ if len(ln) > width {
629+ ln = v.fit(ln, width)
630+ }
631+ out = append(out, t.S().Muted.
632+ Width(width).
633+ Background(t.BgSubtle).
634+ Render(ln))
635+ }
636+
637+ if len(lines) > responseContextHeight {
638+ out = append(out, t.S().Muted.
639+ Background(t.BgSubtle).
640+ Width(width).
641+ Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
642+ }
643+ return strings.Join(out, "\n")
644+}
645+
646+func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
647+ t := styles.CurrentTheme()
648+ truncated := truncateHeight(content, responseContextHeight)
649+
650+ highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgSubtle)
651+ lines := strings.Split(highlighted, "\n")
652+
653+ if len(strings.Split(content, "\n")) > responseContextHeight {
654+ lines = append(lines, t.S().Muted.
655+ Background(t.BgSubtle).
656+ Width(v.textWidth()-2).
657+ Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
658+ }
659+
660+ for i, ln := range lines {
661+ num := t.S().Muted.
662+ Background(t.BgSubtle).
663+ PaddingLeft(4).
664+ PaddingRight(2).
665+ Render(fmt.Sprintf("%d", i+1+offset))
666+ w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
667+ lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
668+ num,
669+ t.S().Base.
670+ Width(w).
671+ Background(t.BgSubtle).
672+ Render(v.fit(ln, w)))
673+ }
674+ return lipgloss.JoinVertical(lipgloss.Left, lines...)
675+}
676+
677+func (v *toolCallCmp) renderToolError() string {
678+ t := styles.CurrentTheme()
679+ err := strings.ReplaceAll(v.result.Content, "\n", " ")
680+ err = fmt.Sprintf("Error: %s", err)
681+ return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
682+}
683+
684+func truncateHeight(s string, h int) string {
685+ lines := strings.Split(s, "\n")
686+ if len(lines) > h {
687+ return strings.Join(lines[:h], "\n")
688+ }
689+ return s
690+}
691+
692+func prettifyToolName(name string) string {
693+ switch name {
694+ case agent.AgentToolName:
695+ return "Task"
696+ case tools.BashToolName:
697+ return "Bash"
698+ case tools.EditToolName:
699+ return "Edit"
700+ case tools.FetchToolName:
701+ return "Fetch"
702+ case tools.GlobToolName:
703+ return "Glob"
704+ case tools.GrepToolName:
705+ return "Grep"
706+ case tools.LSToolName:
707+ return "List"
708+ case tools.SourcegraphToolName:
709+ return "Sourcegraph"
710+ case tools.ViewToolName:
711+ return "View"
712+ case tools.WriteToolName:
713+ return "Write"
714+ case tools.PatchToolName:
715+ return "Patch"
716+ default:
717+ return name
718+ }
719+}
internal/tui/components/chat/messages/tool.gonew file+295
@@ -0,0 +1,295 @@
1+package messages
2+
3+import (
4+ "fmt"
5+
6+ "github.com/charmbracelet/bubbles/v2/spinner"
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/tui/components/anim"
10+ "github.com/charmbracelet/crush/internal/tui/layout"
11+ "github.com/charmbracelet/crush/internal/tui/styles"
12+ "github.com/charmbracelet/crush/internal/tui/util"
13+ "github.com/charmbracelet/lipgloss/v2"
14+ "github.com/charmbracelet/x/ansi"
15+)
16+
17+// ToolCallCmp defines the interface for tool call components in the chat interface.
18+// It manages the display of tool execution including pending states, results, and errors.
19+type ToolCallCmp interface {
20+ util.Model // Basic Bubble Tea model interface
21+ layout.Sizeable // Width/height management
22+ layout.Focusable // Focus state management
23+ GetToolCall() message.ToolCall // Access to tool call data
24+ GetToolResult() message.ToolResult // Access to tool result data
25+ SetToolResult(message.ToolResult) // Update tool result
26+ SetToolCall(message.ToolCall) // Update tool call
27+ SetCancelled() // Mark as cancelled
28+ ParentMessageId() string // Get parent message ID
29+ Spinning() bool // Animation state for pending tools
30+ GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
31+ SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
32+ SetIsNested(bool) // Set whether this tool call is nested
33+}
34+
35+// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
36+// It handles rendering of tool execution states including pending, completed, and error states.
37+type toolCallCmp struct {
38+ width int // Component width for text wrapping
39+ focused bool // Focus state for border styling
40+ isNested bool // Whether this tool call is nested within another
41+
42+ // Tool call data and state
43+ parentMessageID string // ID of the message that initiated this tool call
44+ call message.ToolCall // The tool call being executed
45+ result message.ToolResult // The result of the tool execution
46+ cancelled bool // Whether the tool call was cancelled
47+
48+ // Animation state for pending tool calls
49+ spinning bool // Whether to show loading animation
50+ anim util.Model // Animation component for pending states
51+
52+ nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
53+}
54+
55+// ToolCallOption provides functional options for configuring tool call components
56+type ToolCallOption func(*toolCallCmp)
57+
58+// WithToolCallCancelled marks the tool call as cancelled
59+func WithToolCallCancelled() ToolCallOption {
60+ return func(m *toolCallCmp) {
61+ m.cancelled = true
62+ }
63+}
64+
65+// WithToolCallResult sets the initial tool result
66+func WithToolCallResult(result message.ToolResult) ToolCallOption {
67+ return func(m *toolCallCmp) {
68+ m.result = result
69+ }
70+}
71+
72+func WithToolCallNested(isNested bool) ToolCallOption {
73+ return func(m *toolCallCmp) {
74+ m.isNested = isNested
75+ }
76+}
77+
78+func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
79+ return func(m *toolCallCmp) {
80+ m.nestedToolCalls = calls
81+ }
82+}
83+
84+// NewToolCallCmp creates a new tool call component with the given parent message ID,
85+// tool call, and optional configuration
86+func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
87+ m := &toolCallCmp{
88+ call: tc,
89+ parentMessageID: parentMessageId,
90+ }
91+ for _, opt := range opts {
92+ opt(m)
93+ }
94+ m.anim = anim.New(15, "Working")
95+ if m.isNested {
96+ m.anim = anim.New(10, "")
97+ }
98+ return m
99+}
100+
101+// Init initializes the tool call component and starts animations if needed.
102+// Returns a command to start the animation for pending tool calls.
103+func (m *toolCallCmp) Init() tea.Cmd {
104+ m.spinning = m.shouldSpin()
105+ if m.spinning {
106+ return m.anim.Init()
107+ }
108+ return nil
109+}
110+
111+// Update handles incoming messages and updates the component state.
112+// Manages animation updates for pending tool calls.
113+func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114+ switch msg := msg.(type) {
115+ case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
116+ var cmds []tea.Cmd
117+ for i, nested := range m.nestedToolCalls {
118+ if nested.Spinning() {
119+ u, cmd := nested.Update(msg)
120+ m.nestedToolCalls[i] = u.(ToolCallCmp)
121+ cmds = append(cmds, cmd)
122+ }
123+ }
124+ if m.spinning {
125+ u, cmd := m.anim.Update(msg)
126+ m.anim = u.(util.Model)
127+ cmds = append(cmds, cmd)
128+ }
129+ return m, tea.Batch(cmds...)
130+ }
131+ return m, nil
132+}
133+
134+// View renders the tool call component based on its current state.
135+// Shows either a pending animation or the tool-specific rendered result.
136+func (m *toolCallCmp) View() tea.View {
137+ box := m.style()
138+
139+ if !m.call.Finished && !m.cancelled {
140+ if m.isNested {
141+ return tea.NewView(box.Render(m.renderPending()))
142+ }
143+ return tea.NewView(box.Render(m.renderPending()))
144+ }
145+
146+ r := registry.lookup(m.call.Name)
147+
148+ if m.isNested {
149+ return tea.NewView(box.Render(r.Render(m)))
150+ }
151+ return tea.NewView(box.Render(r.Render(m)))
152+}
153+
154+// State management methods
155+
156+// SetCancelled marks the tool call as cancelled
157+func (m *toolCallCmp) SetCancelled() {
158+ m.cancelled = true
159+}
160+
161+// SetToolCall updates the tool call data and stops spinning if finished
162+func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
163+ m.call = call
164+ if m.call.Finished {
165+ m.spinning = false
166+ }
167+}
168+
169+// ParentMessageId returns the ID of the message that initiated this tool call
170+func (m *toolCallCmp) ParentMessageId() string {
171+ return m.parentMessageID
172+}
173+
174+// SetToolResult updates the tool result and stops the spinning animation
175+func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
176+ m.result = result
177+ m.spinning = false
178+}
179+
180+// GetToolCall returns the current tool call data
181+func (m *toolCallCmp) GetToolCall() message.ToolCall {
182+ return m.call
183+}
184+
185+// GetToolResult returns the current tool result data
186+func (m *toolCallCmp) GetToolResult() message.ToolResult {
187+ return m.result
188+}
189+
190+// GetNestedToolCalls returns the nested tool calls
191+func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
192+ return m.nestedToolCalls
193+}
194+
195+// SetNestedToolCalls sets the nested tool calls
196+func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
197+ m.nestedToolCalls = calls
198+ for _, nested := range m.nestedToolCalls {
199+ nested.SetSize(m.width, 0)
200+ }
201+}
202+
203+// SetIsNested sets whether this tool call is nested within another
204+func (m *toolCallCmp) SetIsNested(isNested bool) {
205+ m.isNested = isNested
206+}
207+
208+// Rendering methods
209+
210+// renderPending displays the tool name with a loading animation for pending tool calls
211+func (m *toolCallCmp) renderPending() string {
212+ t := styles.CurrentTheme()
213+ icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
214+ tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
215+ return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
216+}
217+
218+// style returns the lipgloss style for the tool call component.
219+// Applies muted colors and focus-dependent border styles.
220+func (m *toolCallCmp) style() lipgloss.Style {
221+ t := styles.CurrentTheme()
222+
223+ if m.isNested {
224+ return t.S().Muted
225+ }
226+ return t.S().Muted.PaddingLeft(4)
227+}
228+
229+// textWidth calculates the available width for text content,
230+// accounting for borders and padding
231+func (m *toolCallCmp) textWidth() int {
232+ return m.width - 5 // take into account the border and PaddingLeft
233+}
234+
235+// fit truncates content to fit within the specified width with ellipsis
236+func (m *toolCallCmp) fit(content string, width int) string {
237+ t := styles.CurrentTheme()
238+ lineStyle := t.S().Muted.Background(t.BgSubtle)
239+ dots := lineStyle.Render("...")
240+ return ansi.Truncate(content, width, dots)
241+}
242+
243+// Focus management methods
244+
245+// Blur removes focus from the tool call component
246+func (m *toolCallCmp) Blur() tea.Cmd {
247+ m.focused = false
248+ return nil
249+}
250+
251+// Focus sets focus on the tool call component
252+func (m *toolCallCmp) Focus() tea.Cmd {
253+ m.focused = true
254+ return nil
255+}
256+
257+// IsFocused returns whether the tool call component is currently focused
258+func (m *toolCallCmp) IsFocused() bool {
259+ return m.focused
260+}
261+
262+// Size management methods
263+
264+// GetSize returns the current dimensions of the tool call component
265+func (m *toolCallCmp) GetSize() (int, int) {
266+ return m.width, 0
267+}
268+
269+// SetSize updates the width of the tool call component for text wrapping
270+func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
271+ m.width = width
272+ for _, nested := range m.nestedToolCalls {
273+ nested.SetSize(width, height)
274+ }
275+ return nil
276+}
277+
278+// shouldSpin determines whether the tool call should show a loading animation.
279+// Returns true if the tool call is not finished or if the result doesn't match the call ID.
280+func (m *toolCallCmp) shouldSpin() bool {
281+ return !m.call.Finished
282+}
283+
284+// Spinning returns whether the tool call is currently showing a loading animation
285+func (m *toolCallCmp) Spinning() bool {
286+ if m.spinning {
287+ return true
288+ }
289+ for _, nested := range m.nestedToolCalls {
290+ if nested.Spinning() {
291+ return true
292+ }
293+ }
294+ return m.spinning
295+}
internal/tui/components/chat/sidebar.godeleted−378
@@ -1,378 +0,0 @@
1−package chat
2−
3−import (
4− "context"
5− "fmt"
6− "sort"
7− "strings"
8−
9− tea "github.com/charmbracelet/bubbletea"
10− "github.com/charmbracelet/lipgloss"
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/diff"
13− "github.com/opencode-ai/opencode/internal/history"
14− "github.com/opencode-ai/opencode/internal/pubsub"
15− "github.com/opencode-ai/opencode/internal/session"
16− "github.com/opencode-ai/opencode/internal/tui/styles"
17− "github.com/opencode-ai/opencode/internal/tui/theme"
18−)
19−
20−type sidebarCmp struct {
21− width, height int
22− session session.Session
23− history history.Service
24− modFiles map[string]struct {
25− additions int
26− removals int
27− }
28−}
29−
30−func (m *sidebarCmp) Init() tea.Cmd {
31− if m.history != nil {
32− ctx := context.Background()
33− // Subscribe to file events
34− filesCh := m.history.Subscribe(ctx)
35−
36− // Initialize the modified files map
37− m.modFiles = make(map[string]struct {
38− additions int
39− removals int
40− })
41−
42− // Load initial files and calculate diffs
43− m.loadModifiedFiles(ctx)
44−
45− // Return a command that will send file events to the Update method
46− return func() tea.Msg {
47− return <-filesCh
48− }
49− }
50− return nil
51−}
52−
53−func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
54− switch msg := msg.(type) {
55− case SessionSelectedMsg:
56− if msg.ID != m.session.ID {
57− m.session = msg
58− ctx := context.Background()
59− m.loadModifiedFiles(ctx)
60− }
61− case pubsub.Event[session.Session]:
62− if msg.Type == pubsub.UpdatedEvent {
63− if m.session.ID == msg.Payload.ID {
64− m.session = msg.Payload
65− }
66− }
67− case pubsub.Event[history.File]:
68− if msg.Payload.SessionID == m.session.ID {
69− // Process the individual file change instead of reloading all files
70− ctx := context.Background()
71− m.processFileChanges(ctx, msg.Payload)
72−
73− // Return a command to continue receiving events
74− return m, func() tea.Msg {
75− ctx := context.Background()
76− filesCh := m.history.Subscribe(ctx)
77− return <-filesCh
78− }
79− }
80− }
81− return m, nil
82−}
83−
84−func (m *sidebarCmp) View() string {
85− baseStyle := styles.BaseStyle()
86−
87− return baseStyle.
88− Width(m.width).
89− PaddingLeft(4).
90− PaddingRight(2).
91− Height(m.height - 1).
92− Render(
93− lipgloss.JoinVertical(
94− lipgloss.Top,
95− header(m.width),
96− " ",
97− m.sessionSection(),
98− " ",
99− lspsConfigured(m.width),
100− " ",
101− m.modifiedFiles(),
102− ),
103− )
104−}
105−
106−func (m *sidebarCmp) sessionSection() string {
107− t := theme.CurrentTheme()
108− baseStyle := styles.BaseStyle()
109−
110− sessionKey := baseStyle.
111− Foreground(t.Primary()).
112− Bold(true).
113− Render("Session")
114−
115− sessionValue := baseStyle.
116− Foreground(t.Text()).
117− Width(m.width - lipgloss.Width(sessionKey)).
118− Render(fmt.Sprintf(": %s", m.session.Title))
119−
120− return lipgloss.JoinHorizontal(
121− lipgloss.Left,
122− sessionKey,
123− sessionValue,
124− )
125−}
126−
127−func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
128− t := theme.CurrentTheme()
129− baseStyle := styles.BaseStyle()
130−
131− stats := ""
132− if additions > 0 && removals > 0 {
133− additionsStr := baseStyle.
134− Foreground(t.Success()).
135− PaddingLeft(1).
136− Render(fmt.Sprintf("+%d", additions))
137−
138− removalsStr := baseStyle.
139− Foreground(t.Error()).
140− PaddingLeft(1).
141− Render(fmt.Sprintf("-%d", removals))
142−
143− content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
144− stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
145− } else if additions > 0 {
146− additionsStr := fmt.Sprintf(" %s", baseStyle.
147− PaddingLeft(1).
148− Foreground(t.Success()).
149− Render(fmt.Sprintf("+%d", additions)))
150− stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
151− } else if removals > 0 {
152− removalsStr := fmt.Sprintf(" %s", baseStyle.
153− PaddingLeft(1).
154− Foreground(t.Error()).
155− Render(fmt.Sprintf("-%d", removals)))
156− stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
157− }
158−
159− filePathStr := baseStyle.Render(filePath)
160−
161− return baseStyle.
162− Width(m.width).
163− Render(
164− lipgloss.JoinHorizontal(
165− lipgloss.Left,
166− filePathStr,
167− stats,
168− ),
169− )
170−}
171−
172−func (m *sidebarCmp) modifiedFiles() string {
173− t := theme.CurrentTheme()
174− baseStyle := styles.BaseStyle()
175−
176− modifiedFiles := baseStyle.
177− Width(m.width).
178− Foreground(t.Primary()).
179− Bold(true).
180− Render("Modified Files:")
181−
182− // If no modified files, show a placeholder message
183− if m.modFiles == nil || len(m.modFiles) == 0 {
184− message := "No modified files"
185− remainingWidth := m.width - lipgloss.Width(message)
186− if remainingWidth > 0 {
187− message += strings.Repeat(" ", remainingWidth)
188− }
189− return baseStyle.
190− Width(m.width).
191− Render(
192− lipgloss.JoinVertical(
193− lipgloss.Top,
194− modifiedFiles,
195− baseStyle.Foreground(t.TextMuted()).Render(message),
196− ),
197− )
198− }
199−
200− // Sort file paths alphabetically for consistent ordering
201− var paths []string
202− for path := range m.modFiles {
203− paths = append(paths, path)
204− }
205− sort.Strings(paths)
206−
207− // Create views for each file in sorted order
208− var fileViews []string
209− for _, path := range paths {
210− stats := m.modFiles[path]
211− fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
212− }
213−
214− return baseStyle.
215− Width(m.width).
216− Render(
217− lipgloss.JoinVertical(
218− lipgloss.Top,
219− modifiedFiles,
220− lipgloss.JoinVertical(
221− lipgloss.Left,
222− fileViews...,
223− ),
224− ),
225− )
226−}
227−
228−func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
229− m.width = width
230− m.height = height
231− return nil
232−}
233−
234−func (m *sidebarCmp) GetSize() (int, int) {
235− return m.width, m.height
236−}
237−
238−func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
239− return &sidebarCmp{
240− session: session,
241− history: history,
242− }
243−}
244−
245−func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
246− if m.history == nil || m.session.ID == "" {
247− return
248− }
249−
250− // Get all latest files for this session
251− latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
252− if err != nil {
253− return
254− }
255−
256− // Get all files for this session (to find initial versions)
257− allFiles, err := m.history.ListBySession(ctx, m.session.ID)
258− if err != nil {
259− return
260− }
261−
262− // Clear the existing map to rebuild it
263− m.modFiles = make(map[string]struct {
264− additions int
265− removals int
266− })
267−
268− // Process each latest file
269− for _, file := range latestFiles {
270− // Skip if this is the initial version (no changes to show)
271− if file.Version == history.InitialVersion {
272− continue
273− }
274−
275− // Find the initial version for this specific file
276− var initialVersion history.File
277− for _, v := range allFiles {
278− if v.Path == file.Path && v.Version == history.InitialVersion {
279− initialVersion = v
280− break
281− }
282− }
283−
284− // Skip if we can't find the initial version
285− if initialVersion.ID == "" {
286− continue
287− }
288− if initialVersion.Content == file.Content {
289− continue
290− }
291−
292− // Calculate diff between initial and latest version
293− _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
294−
295− // Only add to modified files if there are changes
296− if additions > 0 || removals > 0 {
297− // Remove working directory prefix from file path
298− displayPath := file.Path
299− workingDir := config.WorkingDirectory()
300− displayPath = strings.TrimPrefix(displayPath, workingDir)
301− displayPath = strings.TrimPrefix(displayPath, "/")
302−
303− m.modFiles[displayPath] = struct {
304− additions int
305− removals int
306− }{
307− additions: additions,
308− removals: removals,
309− }
310− }
311− }
312−}
313−
314−func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
315− // Skip if this is the initial version (no changes to show)
316− if file.Version == history.InitialVersion {
317− return
318− }
319−
320− // Find the initial version for this file
321− initialVersion, err := m.findInitialVersion(ctx, file.Path)
322− if err != nil || initialVersion.ID == "" {
323− return
324− }
325−
326− // Skip if content hasn't changed
327− if initialVersion.Content == file.Content {
328− // If this file was previously modified but now matches the initial version,
329− // remove it from the modified files list
330− displayPath := getDisplayPath(file.Path)
331− delete(m.modFiles, displayPath)
332− return
333− }
334−
335− // Calculate diff between initial and latest version
336− _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
337−
338− // Only add to modified files if there are changes
339− if additions > 0 || removals > 0 {
340− displayPath := getDisplayPath(file.Path)
341− m.modFiles[displayPath] = struct {
342− additions int
343− removals int
344− }{
345− additions: additions,
346− removals: removals,
347− }
348− } else {
349− // If no changes, remove from modified files
350− displayPath := getDisplayPath(file.Path)
351− delete(m.modFiles, displayPath)
352− }
353−}
354−
355−// Helper function to find the initial version of a file
356−func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
357− // Get all versions of this file for the session
358− fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
359− if err != nil {
360− return history.File{}, err
361− }
362−
363− // Find the initial version
364− for _, v := range fileVersions {
365− if v.Path == path && v.Version == history.InitialVersion {
366− return v, nil
367− }
368− }
369−
370− return history.File{}, fmt.Errorf("initial version not found")
371−}
372−
373−// Helper function to get the display path for a file
374−func getDisplayPath(path string) string {
375− workingDir := config.WorkingDirectory()
376− displayPath := strings.TrimPrefix(path, workingDir)
377− return strings.TrimPrefix(displayPath, "/")
378−}
internal/tui/components/chat/sidebar/sidebar.gonew file+207
@@ -0,0 +1,207 @@
1+package sidebar
2+
3+import (
4+ "os"
5+ "strings"
6+
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/pubsub"
10+ "github.com/charmbracelet/crush/internal/session"
11+ "github.com/charmbracelet/crush/internal/tui/components/chat"
12+ "github.com/charmbracelet/crush/internal/tui/components/core"
13+ "github.com/charmbracelet/crush/internal/tui/components/logo"
14+ "github.com/charmbracelet/crush/internal/tui/layout"
15+ "github.com/charmbracelet/crush/internal/tui/styles"
16+ "github.com/charmbracelet/crush/internal/tui/util"
17+ "github.com/charmbracelet/crush/internal/version"
18+ "github.com/charmbracelet/lipgloss/v2"
19+)
20+
21+const (
22+ logoBreakpoint = 65
23+)
24+
25+type Sidebar interface {
26+ util.Model
27+ layout.Sizeable
28+}
29+
30+type sidebarCmp struct {
31+ width, height int
32+ session session.Session
33+ logo string
34+ cwd string
35+}
36+
37+func NewSidebarCmp() Sidebar {
38+ return &sidebarCmp{}
39+}
40+
41+func (m *sidebarCmp) Init() tea.Cmd {
42+ m.logo = m.logoBlock(false)
43+ m.cwd = cwd()
44+ return nil
45+}
46+
47+func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
48+ switch msg := msg.(type) {
49+ case chat.SessionSelectedMsg:
50+ if msg.ID != m.session.ID {
51+ m.session = msg
52+ }
53+ case chat.SessionClearedMsg:
54+ m.session = session.Session{}
55+ case pubsub.Event[session.Session]:
56+ if msg.Type == pubsub.UpdatedEvent {
57+ if m.session.ID == msg.Payload.ID {
58+ m.session = msg.Payload
59+ }
60+ }
61+ }
62+ return m, nil
63+}
64+
65+func (m *sidebarCmp) View() tea.View {
66+ t := styles.CurrentTheme()
67+ parts := []string{
68+ m.logo,
69+ }
70+
71+ if m.session.ID != "" {
72+ parts = append(parts, t.S().Muted.Render(m.session.Title), "")
73+ }
74+
75+ parts = append(parts,
76+ m.cwd,
77+ "",
78+ m.lspBlock(),
79+ "",
80+ m.mcpBlock(),
81+ )
82+
83+ return tea.NewView(
84+ lipgloss.JoinVertical(lipgloss.Left, parts...),
85+ )
86+}
87+
88+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
89+ if width < logoBreakpoint && m.width >= logoBreakpoint {
90+ m.logo = m.logoBlock(true)
91+ } else if width >= logoBreakpoint && m.width < logoBreakpoint {
92+ m.logo = m.logoBlock(false)
93+ }
94+
95+ m.width = width
96+ m.height = height
97+ return nil
98+}
99+
100+func (m *sidebarCmp) GetSize() (int, int) {
101+ return m.width, m.height
102+}
103+
104+func (m *sidebarCmp) logoBlock(compact bool) string {
105+ t := styles.CurrentTheme()
106+ return logo.Render(version.Version, compact, logo.Opts{
107+ FieldColor: t.Primary,
108+ TitleColorA: t.Secondary,
109+ TitleColorB: t.Primary,
110+ CharmColor: t.Secondary,
111+ VersionColor: t.Primary,
112+ })
113+}
114+
115+func (m *sidebarCmp) lspBlock() string {
116+ maxWidth := min(m.width, 58)
117+ t := styles.CurrentTheme()
118+
119+ section := t.S().Muted.Render(
120+ core.Section("LSPs", maxWidth),
121+ )
122+
123+ lspList := []string{section, ""}
124+
125+ lsp := config.Get().LSP
126+ if len(lsp) == 0 {
127+ return lipgloss.JoinVertical(
128+ lipgloss.Left,
129+ section,
130+ "",
131+ t.S().Base.Foreground(t.Border).Render("None"),
132+ )
133+ }
134+
135+ for n, l := range lsp {
136+ iconColor := t.Success
137+ if l.Disabled {
138+ iconColor = t.FgMuted
139+ }
140+ lspList = append(lspList,
141+ core.Status(
142+ core.StatusOpts{
143+ IconColor: iconColor,
144+ Title: n,
145+ Description: l.Command,
146+ },
147+ m.width,
148+ ),
149+ )
150+ }
151+
152+ return lipgloss.JoinVertical(
153+ lipgloss.Left,
154+ lspList...,
155+ )
156+}
157+
158+func (m *sidebarCmp) mcpBlock() string {
159+ maxWidth := min(m.width, 58)
160+ t := styles.CurrentTheme()
161+
162+ section := t.S().Muted.Render(
163+ core.Section("MCPs", maxWidth),
164+ )
165+
166+ mcpList := []string{section, ""}
167+
168+ mcp := config.Get().MCPServers
169+ if len(mcp) == 0 {
170+ return lipgloss.JoinVertical(
171+ lipgloss.Left,
172+ section,
173+ "",
174+ t.S().Base.Foreground(t.Border).Render("None"),
175+ )
176+ }
177+
178+ for n, l := range mcp {
179+ iconColor := t.Success
180+ mcpList = append(mcpList,
181+ core.Status(
182+ core.StatusOpts{
183+ IconColor: iconColor,
184+ Title: n,
185+ Description: l.Command,
186+ },
187+ m.width,
188+ ),
189+ )
190+ }
191+
192+ return lipgloss.JoinVertical(
193+ lipgloss.Left,
194+ mcpList...,
195+ )
196+}
197+
198+func cwd() string {
199+ cwd := config.WorkingDirectory()
200+ t := styles.CurrentTheme()
201+ // replace home directory with ~
202+ homeDir, err := os.UserHomeDir()
203+ if err == nil {
204+ cwd = strings.ReplaceAll(cwd, homeDir, "~")
205+ }
206+ return t.S().Muted.Render(cwd)
207+}
internal/tui/components/completions/completions.gonew file+195
@@ -0,0 +1,195 @@
1+package completions
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
7+ "github.com/charmbracelet/crush/internal/tui/styles"
8+ "github.com/charmbracelet/crush/internal/tui/util"
9+ "github.com/charmbracelet/lipgloss/v2"
10+)
11+
12+type Completion struct {
13+ Title string // The title of the completion item
14+ Value any // The value of the completion item
15+}
16+
17+type OpenCompletionsMsg struct {
18+ Completions []Completion
19+ X int // X position for the completions popup
20+ Y int // Y position for the completions popup
21+}
22+
23+type FilterCompletionsMsg struct {
24+ Query string // The query to filter completions
25+}
26+
27+type CompletionsClosedMsg struct{}
28+
29+type CloseCompletionsMsg struct{}
30+
31+type SelectCompletionMsg struct {
32+ Value any // The value of the selected completion item
33+}
34+
35+type Completions interface {
36+ util.Model
37+ Open() bool
38+ Query() string // Returns the current filter query
39+ KeyMap() KeyMap
40+ Position() (int, int) // Returns the X and Y position of the completions popup
41+}
42+
43+type completionsCmp struct {
44+ width int
45+ height int // Height of the completions component`
46+ x int // X position for the completions popup\
47+ y int // Y position for the completions popup
48+ open bool // Indicates if the completions are open
49+ keyMap KeyMap
50+
51+ list list.ListModel
52+ query string // The current filter query
53+}
54+
55+func New() Completions {
56+ completionsKeyMap := DefaultKeyMap()
57+ keyMap := list.DefaultKeyMap()
58+ keyMap.Up.SetEnabled(false)
59+ keyMap.Down.SetEnabled(false)
60+ keyMap.NDown.SetEnabled(false)
61+ keyMap.NUp.SetEnabled(false)
62+ keyMap.HalfPageDown.SetEnabled(false)
63+ keyMap.HalfPageUp.SetEnabled(false)
64+ keyMap.Home.SetEnabled(false)
65+ keyMap.End.SetEnabled(false)
66+ keyMap.UpOneItem = completionsKeyMap.Up
67+ keyMap.DownOneItem = completionsKeyMap.Down
68+
69+ l := list.New(
70+ list.WithReverse(true),
71+ list.WithKeyMap(keyMap),
72+ list.WithHideFilterInput(true),
73+ )
74+ return &completionsCmp{
75+ width: 30,
76+ height: 10,
77+ list: l,
78+ query: "",
79+ keyMap: completionsKeyMap,
80+ }
81+}
82+
83+// Init implements Completions.
84+func (c *completionsCmp) Init() tea.Cmd {
85+ return tea.Sequence(
86+ c.list.Init(),
87+ c.list.SetSize(c.width, c.height),
88+ )
89+}
90+
91+// Update implements Completions.
92+func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
93+ switch msg := msg.(type) {
94+ case tea.KeyPressMsg:
95+ switch {
96+ case key.Matches(msg, c.keyMap.Up):
97+ u, cmd := c.list.Update(msg)
98+ c.list = u.(list.ListModel)
99+ return c, cmd
100+
101+ case key.Matches(msg, c.keyMap.Down):
102+ d, cmd := c.list.Update(msg)
103+ c.list = d.(list.ListModel)
104+ return c, cmd
105+ case key.Matches(msg, c.keyMap.Select):
106+ selectedItemInx := c.list.SelectedIndex()
107+ if selectedItemInx == list.NoSelection {
108+ return c, nil // No item selected, do nothing
109+ }
110+ items := c.list.Items()
111+ selectedItem := items[selectedItemInx].(CompletionItem).Value()
112+ c.open = false // Close completions after selection
113+ return c, util.CmdHandler(SelectCompletionMsg{
114+ Value: selectedItem,
115+ })
116+ case key.Matches(msg, c.keyMap.Cancel):
117+ if c.open {
118+ c.open = false
119+ return c, util.CmdHandler(CompletionsClosedMsg{})
120+ }
121+ }
122+ case CloseCompletionsMsg:
123+ c.open = false
124+ c.query = ""
125+ return c, tea.Batch(
126+ c.list.SetItems([]util.Model{}),
127+ util.CmdHandler(CompletionsClosedMsg{}),
128+ )
129+ case OpenCompletionsMsg:
130+ c.open = true
131+ c.query = ""
132+ c.x = msg.X
133+ c.y = msg.Y
134+ items := []util.Model{}
135+ t := styles.CurrentTheme()
136+ for _, completion := range msg.Completions {
137+ item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
138+ items = append(items, item)
139+ }
140+ c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height
141+ cmds := []tea.Cmd{
142+ c.list.SetSize(c.width, c.height),
143+ c.list.SetItems(items),
144+ }
145+ return c, tea.Batch(cmds...)
146+ case FilterCompletionsMsg:
147+ c.query = msg.Query
148+ if !c.open {
149+ return c, nil // If completions are not open, do nothing
150+ }
151+ cmd := c.list.Filter(msg.Query)
152+ c.height = max(min(10, len(c.list.Items())), 1)
153+ return c, tea.Batch(
154+ cmd,
155+ c.list.SetSize(c.width, c.height),
156+ )
157+ }
158+ return c, nil
159+}
160+
161+// View implements Completions.
162+func (c *completionsCmp) View() tea.View {
163+ if len(c.list.Items()) == 0 {
164+ return tea.NewView(c.style().Render("No completions found"))
165+ }
166+
167+ view := tea.NewView(
168+ c.style().Render(c.list.View().String()),
169+ )
170+ return view
171+}
172+
173+func (c *completionsCmp) style() lipgloss.Style {
174+ t := styles.CurrentTheme()
175+ return t.S().Base.
176+ Width(c.width).
177+ Height(c.height).
178+ Background(t.BgSubtle)
179+}
180+
181+func (c *completionsCmp) Open() bool {
182+ return c.open
183+}
184+
185+func (c *completionsCmp) Query() string {
186+ return c.query
187+}
188+
189+func (c *completionsCmp) KeyMap() KeyMap {
190+ return c.keyMap
191+}
192+
193+func (c *completionsCmp) Position() (int, int) {
194+ return c.x, c.y - c.height
195+}
internal/tui/components/completions/item.gonew file+281
@@ -0,0 +1,281 @@
1+package completions
2+
3+import (
4+ "image/color"
5+
6+ tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
8+ "github.com/charmbracelet/crush/internal/tui/layout"
9+ "github.com/charmbracelet/crush/internal/tui/styles"
10+ "github.com/charmbracelet/crush/internal/tui/util"
11+ "github.com/charmbracelet/lipgloss/v2"
12+ "github.com/charmbracelet/x/ansi"
13+ "github.com/rivo/uniseg"
14+)
15+
16+type CompletionItem interface {
17+ util.Model
18+ layout.Focusable
19+ layout.Sizeable
20+ list.HasMatchIndexes
21+ list.HasFilterValue
22+ Value() any
23+}
24+
25+type completionItemCmp struct {
26+ width int
27+ text string
28+ value any
29+ focus bool
30+ matchIndexes []int
31+ bgColor color.Color
32+ shortcut string
33+}
34+
35+type CompletionOption func(*completionItemCmp)
36+
37+func WithBackgroundColor(c color.Color) CompletionOption {
38+ return func(cmp *completionItemCmp) {
39+ cmp.bgColor = c
40+ }
41+}
42+
43+func WithMatchIndexes(indexes ...int) CompletionOption {
44+ return func(cmp *completionItemCmp) {
45+ cmp.matchIndexes = indexes
46+ }
47+}
48+
49+func WithShortcut(shortcut string) CompletionOption {
50+ return func(cmp *completionItemCmp) {
51+ cmp.shortcut = shortcut
52+ }
53+}
54+
55+func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
56+ c := &completionItemCmp{
57+ text: text,
58+ value: value,
59+ }
60+
61+ for _, opt := range opts {
62+ opt(c)
63+ }
64+ return c
65+}
66+
67+// Init implements CommandItem.
68+func (c *completionItemCmp) Init() tea.Cmd {
69+ return nil
70+}
71+
72+// Update implements CommandItem.
73+func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
74+ return c, nil
75+}
76+
77+// View implements CommandItem.
78+func (c *completionItemCmp) View() tea.View {
79+ t := styles.CurrentTheme()
80+
81+ itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
82+ innerWidth := c.width - 2 // Account for padding
83+
84+ if c.shortcut != "" {
85+ innerWidth -= lipgloss.Width(c.shortcut)
86+ }
87+
88+ titleStyle := t.S().Text.Width(innerWidth)
89+ titleMatchStyle := t.S().Text.Underline(true)
90+ if c.bgColor != nil {
91+ titleStyle = titleStyle.Background(c.bgColor)
92+ titleMatchStyle = titleMatchStyle.Background(c.bgColor)
93+ }
94+
95+ if c.focus {
96+ titleStyle = t.S().TextSelected.Width(innerWidth)
97+ titleMatchStyle = t.S().TextSelected.Underline(true)
98+ itemStyle = itemStyle.Background(t.Primary)
99+ }
100+
101+ var truncatedTitle string
102+
103+ if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
104+ // Smart truncation: ensure the last matching part is visible
105+ truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
106+ } else {
107+ // No matches, use regular truncation
108+ truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
109+ }
110+
111+ text := titleStyle.Render(truncatedTitle)
112+ if len(c.matchIndexes) > 0 {
113+ var ranges []lipgloss.Range
114+ for _, rng := range matchedRanges(c.matchIndexes) {
115+ // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
116+ // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
117+ // so we need to adjust it here:
118+ start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
119+ ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
120+ }
121+ text = lipgloss.StyleRanges(text, ranges...)
122+ }
123+ parts := []string{text}
124+ if c.shortcut != "" {
125+ // Add the shortcut at the end
126+ shortcutStyle := t.S().Muted
127+ if c.focus {
128+ shortcutStyle = t.S().TextSelected
129+ }
130+ parts = append(parts, shortcutStyle.Render(c.shortcut))
131+ }
132+ item := itemStyle.Render(
133+ lipgloss.JoinHorizontal(
134+ lipgloss.Left,
135+ parts...,
136+ ),
137+ )
138+ return tea.NewView(item)
139+}
140+
141+// Blur implements CommandItem.
142+func (c *completionItemCmp) Blur() tea.Cmd {
143+ c.focus = false
144+ return nil
145+}
146+
147+// Focus implements CommandItem.
148+func (c *completionItemCmp) Focus() tea.Cmd {
149+ c.focus = true
150+ return nil
151+}
152+
153+// GetSize implements CommandItem.
154+func (c *completionItemCmp) GetSize() (int, int) {
155+ return c.width, 1
156+}
157+
158+// IsFocused implements CommandItem.
159+func (c *completionItemCmp) IsFocused() bool {
160+ return c.focus
161+}
162+
163+// SetSize implements CommandItem.
164+func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
165+ c.width = width
166+ return nil
167+}
168+
169+func (c *completionItemCmp) MatchIndexes(indexes []int) {
170+ c.matchIndexes = indexes
171+}
172+
173+func (c *completionItemCmp) FilterValue() string {
174+ return c.text
175+}
176+
177+func (c *completionItemCmp) Value() any {
178+ return c.value
179+}
180+
181+// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
182+func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
183+ if width <= 0 {
184+ return ""
185+ }
186+
187+ textLen := ansi.StringWidth(text)
188+ if textLen <= width {
189+ return text
190+ }
191+
192+ if len(matchIndexes) == 0 {
193+ return ansi.Truncate(text, width, "…")
194+ }
195+
196+ // Find the last match position
197+ lastMatchPos := matchIndexes[len(matchIndexes)-1]
198+
199+ // Convert byte position to visual width position
200+ lastMatchVisualPos := 0
201+ bytePos := 0
202+ gr := uniseg.NewGraphemes(text)
203+ for bytePos < lastMatchPos && gr.Next() {
204+ bytePos += len(gr.Str())
205+ lastMatchVisualPos += max(1, gr.Width())
206+ }
207+
208+ // Calculate how much space we need for the ellipsis
209+ ellipsisWidth := 1 // "…" character width
210+ availableWidth := width - ellipsisWidth
211+
212+ // If the last match is within the available width, truncate from the end
213+ if lastMatchVisualPos < availableWidth {
214+ return ansi.Truncate(text, width, "…")
215+ }
216+
217+ // Calculate the start position to ensure the last match is visible
218+ // We want to show some context before the last match if possible
219+ startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
220+
221+ // Convert visual position back to byte position
222+ startBytePos := 0
223+ currentVisualPos := 0
224+ gr = uniseg.NewGraphemes(text)
225+ for currentVisualPos < startVisualPos && gr.Next() {
226+ startBytePos += len(gr.Str())
227+ currentVisualPos += max(1, gr.Width())
228+ }
229+
230+ // Extract the substring starting from startBytePos
231+ truncatedText := text[startBytePos:]
232+
233+ // Truncate to fit width with ellipsis
234+ truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
235+ truncatedText = "…" + truncatedText
236+ return truncatedText
237+}
238+
239+func matchedRanges(in []int) [][2]int {
240+ if len(in) == 0 {
241+ return [][2]int{}
242+ }
243+ current := [2]int{in[0], in[0]}
244+ if len(in) == 1 {
245+ return [][2]int{current}
246+ }
247+ var out [][2]int
248+ for i := 1; i < len(in); i++ {
249+ if in[i] == current[1]+1 {
250+ current[1] = in[i]
251+ } else {
252+ out = append(out, current)
253+ current = [2]int{in[i], in[i]}
254+ }
255+ }
256+ out = append(out, current)
257+ return out
258+}
259+
260+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
261+ bytePos, byteStart, byteStop := 0, rng[0], rng[1]
262+ pos, start, stop := 0, 0, 0
263+ gr := uniseg.NewGraphemes(str)
264+ for byteStart > bytePos {
265+ if !gr.Next() {
266+ break
267+ }
268+ bytePos += len(gr.Str())
269+ pos += max(1, gr.Width())
270+ }
271+ start = pos
272+ for byteStop > bytePos {
273+ if !gr.Next() {
274+ break
275+ }
276+ bytePos += len(gr.Str())
277+ pos += max(1, gr.Width())
278+ }
279+ stop = pos
280+ return start, stop
281+}
internal/tui/components/completions/keys.gonew file+53
@@ -0,0 +1,53 @@
1+package completions
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Down,
10+ Up,
11+ Select,
12+ Cancel key.Binding
13+}
14+
15+func DefaultKeyMap() KeyMap {
16+ return KeyMap{
17+ Down: key.NewBinding(
18+ key.WithKeys("down"),
19+ key.WithHelp("down", "move down"),
20+ ),
21+ Up: key.NewBinding(
22+ key.WithKeys("up"),
23+ key.WithHelp("up", "move up"),
24+ ),
25+ Select: key.NewBinding(
26+ key.WithKeys("enter"),
27+ key.WithHelp("enter", "select"),
28+ ),
29+ Cancel: key.NewBinding(
30+ key.WithKeys("esc"),
31+ key.WithHelp("esc", "cancel"),
32+ ),
33+ }
34+}
35+
36+// FullHelp implements help.KeyMap.
37+func (k KeyMap) FullHelp() [][]key.Binding {
38+ m := [][]key.Binding{}
39+ slice := layout.KeyMapToSlice(k)
40+ for i := 0; i < len(slice); i += 4 {
41+ end := min(i+4, len(slice))
42+ m = append(m, slice[i:end])
43+ }
44+ return m
45+}
46+
47+// ShortHelp implements help.KeyMap.
48+func (k KeyMap) ShortHelp() []key.Binding {
49+ return []key.Binding{
50+ k.Up,
51+ k.Down,
52+ }
53+}
internal/tui/components/core/helpers.gonew file+133
@@ -0,0 +1,133 @@
1+package core
2+
3+import (
4+ "image/color"
5+ "strings"
6+
7+ "github.com/charmbracelet/crush/internal/tui/styles"
8+ "github.com/charmbracelet/lipgloss/v2"
9+ "github.com/charmbracelet/x/ansi"
10+)
11+
12+func Section(text string, width int) string {
13+ t := styles.CurrentTheme()
14+ char := "─"
15+ length := lipgloss.Width(text) + 1
16+ remainingWidth := width - length
17+ lineStyle := t.S().Base.Foreground(t.Border)
18+ if remainingWidth > 0 {
19+ text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
20+ }
21+ return text
22+}
23+
24+func Title(title string, width int) string {
25+ t := styles.CurrentTheme()
26+ char := "╱"
27+ length := lipgloss.Width(title) + 1
28+ remainingWidth := width - length
29+ titleStyle := t.S().Base.Foreground(t.Primary)
30+ if remainingWidth > 0 {
31+ lines := strings.Repeat(char, remainingWidth)
32+ lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary)
33+ title = titleStyle.Render(title) + " " + lines
34+ }
35+ return title
36+}
37+
38+type StatusOpts struct {
39+ Icon string
40+ IconColor color.Color
41+ Title string
42+ TitleColor color.Color
43+ Description string
44+ DescriptionColor color.Color
45+}
46+
47+func Status(ops StatusOpts, width int) string {
48+ t := styles.CurrentTheme()
49+ icon := "●"
50+ iconColor := t.Success
51+ if ops.Icon != "" {
52+ icon = ops.Icon
53+ }
54+ if ops.IconColor != nil {
55+ iconColor = ops.IconColor
56+ }
57+ title := ops.Title
58+ titleColor := t.FgMuted
59+ if ops.TitleColor != nil {
60+ titleColor = ops.TitleColor
61+ }
62+ description := ops.Description
63+ descriptionColor := t.FgSubtle
64+ if ops.DescriptionColor != nil {
65+ descriptionColor = ops.DescriptionColor
66+ }
67+ icon = t.S().Base.Foreground(iconColor).Render(icon)
68+ title = t.S().Base.Foreground(titleColor).Render(title)
69+ if description != "" {
70+ description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2, "…")
71+ }
72+ description = t.S().Base.Foreground(descriptionColor).Render(description)
73+ return strings.Join([]string{
74+ icon,
75+ title,
76+ description,
77+ }, " ")
78+}
79+
80+type ButtonOpts struct {
81+ Text string
82+ UnderlineIndex int // Index of character to underline (0-based)
83+ Selected bool // Whether this button is selected
84+}
85+
86+// SelectableButton creates a button with an underlined character and selection state
87+func SelectableButton(opts ButtonOpts) string {
88+ t := styles.CurrentTheme()
89+
90+ // Base style for the button
91+ buttonStyle := t.S().Text
92+
93+ // Apply selection styling
94+ if opts.Selected {
95+ buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary)
96+ } else {
97+ buttonStyle = buttonStyle.Background(t.BgSubtle)
98+ }
99+
100+ // Create the button text with underlined character
101+ text := opts.Text
102+ if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) {
103+ before := text[:opts.UnderlineIndex]
104+ underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1]
105+ after := text[opts.UnderlineIndex+1:]
106+
107+ message := buttonStyle.Render(before) +
108+ buttonStyle.Underline(true).Render(underlined) +
109+ buttonStyle.Render(after)
110+
111+ return buttonStyle.Padding(0, 2).Render(message)
112+ }
113+
114+ // Fallback if no underline index specified
115+ return buttonStyle.Padding(0, 2).Render(text)
116+}
117+
118+// SelectableButtons creates a horizontal row of selectable buttons
119+func SelectableButtons(buttons []ButtonOpts, spacing string) string {
120+ if spacing == "" {
121+ spacing = " "
122+ }
123+
124+ var parts []string
125+ for i, button := range buttons {
126+ parts = append(parts, SelectableButton(button))
127+ if i < len(buttons)-1 {
128+ parts = append(parts, spacing)
129+ }
130+ }
131+
132+ return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
133+}
internal/tui/components/core/list/keys.gonew file+73
@@ -0,0 +1,73 @@
1+package list
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Down,
10+ Up,
11+ NDown,
12+ NUp,
13+ DownOneItem,
14+ UpOneItem,
15+ HalfPageDown,
16+ HalfPageUp,
17+ Home,
18+ End key.Binding
19+}
20+
21+func DefaultKeyMap() KeyMap {
22+ return KeyMap{
23+ Down: key.NewBinding(
24+ key.WithKeys("down", "ctrl+j", "ctrl+n"),
25+ ),
26+ Up: key.NewBinding(
27+ key.WithKeys("up", "ctrl+k", "ctrl+p"),
28+ ),
29+ NDown: key.NewBinding(
30+ key.WithKeys("j"),
31+ ),
32+ NUp: key.NewBinding(
33+ key.WithKeys("k"),
34+ ),
35+ UpOneItem: key.NewBinding(
36+ key.WithKeys("shift+up", "shift+k"),
37+ ),
38+ DownOneItem: key.NewBinding(
39+ key.WithKeys("shift+down", "shift+j"),
40+ ),
41+ HalfPageDown: key.NewBinding(
42+ key.WithKeys("d"),
43+ ),
44+ HalfPageUp: key.NewBinding(
45+ key.WithKeys("u"),
46+ ),
47+ Home: key.NewBinding(
48+ key.WithKeys("g", "home"),
49+ ),
50+ End: key.NewBinding(
51+ key.WithKeys("shift+g", "end"),
52+ ),
53+ }
54+}
55+
56+// FullHelp implements help.KeyMap.
57+func (k KeyMap) FullHelp() [][]key.Binding {
58+ m := [][]key.Binding{}
59+ slice := layout.KeyMapToSlice(k)
60+ for i := 0; i < len(slice); i += 4 {
61+ end := min(i+4, len(slice))
62+ m = append(m, slice[i:end])
63+ }
64+ return m
65+}
66+
67+// ShortHelp implements help.KeyMap.
68+func (k KeyMap) ShortHelp() []key.Binding {
69+ return []key.Binding{
70+ k.Up,
71+ k.Down,
72+ }
73+}
internal/tui/components/core/list/list.gonew file+1360
@@ -0,0 +1,1360 @@
1+package list
2+
3+import (
4+ "slices"
5+ "sort"
6+ "strings"
7+
8+ "github.com/charmbracelet/bubbles/v2/help"
9+ "github.com/charmbracelet/bubbles/v2/key"
10+ "github.com/charmbracelet/bubbles/v2/spinner"
11+ "github.com/charmbracelet/bubbles/v2/textinput"
12+ tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/crush/internal/tui/components/anim"
14+ "github.com/charmbracelet/crush/internal/tui/layout"
15+ "github.com/charmbracelet/crush/internal/tui/styles"
16+ "github.com/charmbracelet/crush/internal/tui/util"
17+ "github.com/charmbracelet/lipgloss/v2"
18+ "github.com/sahilm/fuzzy"
19+)
20+
21+// Constants for special index values and defaults
22+const (
23+ NoSelection = -1 // Indicates no item is currently selected
24+ NotRendered = -1 // Indicates an item hasn't been rendered yet
25+ NoFinalHeight = -1 // Indicates final height hasn't been calculated
26+ DefaultGapSize = 0 // Default spacing between list items
27+)
28+
29+// ListModel defines the interface for a scrollable, selectable list component.
30+// It combines the basic Model interface with sizing capabilities and list-specific operations.
31+type ListModel interface {
32+ util.Model
33+ layout.Sizeable
34+ layout.Focusable
35+ SetItems([]util.Model) tea.Cmd // Replace all items in the list
36+ AppendItem(util.Model) tea.Cmd // Add an item to the end of the list
37+ PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
38+ DeleteItem(int) // Remove an item at the specified index
39+ UpdateItem(int, util.Model) // Replace an item at the specified index
40+ ResetView() // Clear rendering cache and reset scroll position
41+ Items() []util.Model // Get all items in the list
42+ SelectedIndex() int // Get the index of the currently selected item
43+ SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
44+ Filter(string) tea.Cmd // Filter items based on a search term
45+}
46+
47+// HasAnim interface identifies items that support animation.
48+// Items implementing this interface will receive animation update messages.
49+type HasAnim interface {
50+ util.Model
51+ Spinning() bool // Returns true if the item is currently animating
52+}
53+
54+// HasFilterValue interface allows items to provide a filter value for searching.
55+type HasFilterValue interface {
56+ FilterValue() string // Returns a string value used for filtering/searching
57+}
58+
59+// HasMatchIndexes interface allows items to set matched character indexes.
60+type HasMatchIndexes interface {
61+ MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
62+}
63+
64+// SectionHeader interface identifies items that are section headers.
65+// Section headers are rendered differently and are skipped during navigation.
66+type SectionHeader interface {
67+ util.Model
68+ IsSectionHeader() bool // Returns true if this item is a section header
69+}
70+
71+// renderedItem represents a cached rendered item with its position and content.
72+type renderedItem struct {
73+ lines []string // The rendered lines of text for this item
74+ start int // Starting line position in the overall rendered content
75+ height int // Number of lines this item occupies
76+}
77+
78+// renderState manages the rendering cache and state for the list.
79+// It tracks which items have been rendered and their positions.
80+type renderState struct {
81+ items map[int]renderedItem // Cache of rendered items by index
82+ lines []string // All rendered lines concatenated
83+ lastIndex int // Index of the last rendered item
84+ finalHeight int // Total height when all items are rendered
85+ needsRerender bool // Flag indicating if re-rendering is needed
86+}
87+
88+// newRenderState creates a new render state with default values.
89+func newRenderState() *renderState {
90+ return &renderState{
91+ items: make(map[int]renderedItem),
92+ lines: []string{},
93+ lastIndex: NotRendered,
94+ finalHeight: NoFinalHeight,
95+ needsRerender: true,
96+ }
97+}
98+
99+// reset clears all cached rendering data and resets state to initial values.
100+func (rs *renderState) reset() {
101+ rs.items = make(map[int]renderedItem)
102+ rs.lines = []string{}
103+ rs.lastIndex = NotRendered
104+ rs.finalHeight = NoFinalHeight
105+ rs.needsRerender = true
106+}
107+
108+// viewState manages the visual display properties of the list.
109+type viewState struct {
110+ width, height int // Dimensions of the list viewport
111+ offset int // Current scroll offset in lines
112+ reverse bool // Whether to render in reverse order (bottom-up)
113+ content string // The final rendered content to display
114+}
115+
116+// selectionState manages which item is currently selected.
117+type selectionState struct {
118+ selectedIndex int // Index of the currently selected item, or NoSelection
119+}
120+
121+// isValidIndex checks if the selected index is within the valid range of items.
122+func (ss *selectionState) isValidIndex(itemCount int) bool {
123+ return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount
124+}
125+
126+// model is the main implementation of the ListModel interface.
127+// It coordinates between view state, render state, and selection state.
128+type model struct {
129+ viewState viewState // Display and scrolling state
130+ renderState *renderState // Rendering cache and state
131+ selectionState selectionState // Item selection state
132+ help help.Model // Help system for keyboard shortcuts
133+ keyMap KeyMap // Key bindings for navigation
134+ allItems []util.Model // The actual list items
135+ gapSize int // Number of empty lines between items
136+ padding []int // Padding around the list content
137+ wrapNavigation bool // Whether to wrap navigation at the ends
138+
139+ filterable bool // Whether items can be filtered
140+ filterPlaceholder string // Placeholder text for filter input
141+ filteredItems []util.Model // Filtered items based on current search
142+ input textinput.Model // Input field for filtering items
143+ inputStyle lipgloss.Style // Style for the input field
144+ hideFilterInput bool // Whether to hide the filter input field
145+ currentSearch string // Current search term for filtering
146+
147+ isFocused bool // Whether the list is currently focused
148+}
149+
150+// listOptions is a function type for configuring list options.
151+type listOptions func(*model)
152+
153+// WithKeyMap sets custom key bindings for the list.
154+func WithKeyMap(k KeyMap) listOptions {
155+ return func(m *model) {
156+ m.keyMap = k
157+ }
158+}
159+
160+// WithReverse sets whether the list should render in reverse order (newest items at bottom).
161+func WithReverse(reverse bool) listOptions {
162+ return func(m *model) {
163+ m.setReverse(reverse)
164+ }
165+}
166+
167+// WithGapSize sets the number of empty lines to insert between list items.
168+func WithGapSize(gapSize int) listOptions {
169+ return func(m *model) {
170+ m.gapSize = gapSize
171+ }
172+}
173+
174+// WithPadding sets the padding around the list content.
175+// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal,
176+// 4 values = top/right/bottom/left.
177+func WithPadding(padding ...int) listOptions {
178+ return func(m *model) {
179+ m.padding = padding
180+ }
181+}
182+
183+// WithItems sets the initial items for the list.
184+func WithItems(items []util.Model) listOptions {
185+ return func(m *model) {
186+ m.allItems = items
187+ m.filteredItems = items // Initially, all items are visible
188+ }
189+}
190+
191+// WithFilterable enables filtering of items based on their FilterValue.
192+func WithFilterable(filterable bool) listOptions {
193+ return func(m *model) {
194+ m.filterable = filterable
195+ }
196+}
197+
198+// WithHideFilterInput hides the filter input field.
199+func WithHideFilterInput(hide bool) listOptions {
200+ return func(m *model) {
201+ m.hideFilterInput = hide
202+ }
203+}
204+
205+// WithFilterPlaceholder sets the placeholder text for the filter input field.
206+func WithFilterPlaceholder(placeholder string) listOptions {
207+ return func(m *model) {
208+ m.filterPlaceholder = placeholder
209+ }
210+}
211+
212+// WithInputStyle sets the style for the filter input field.
213+func WithInputStyle(style lipgloss.Style) listOptions {
214+ return func(m *model) {
215+ m.inputStyle = style
216+ }
217+}
218+
219+// WithWrapNavigation enables wrapping navigation at the ends of the list.
220+func WithWrapNavigation(wrap bool) listOptions {
221+ return func(m *model) {
222+ m.wrapNavigation = wrap
223+ }
224+}
225+
226+// New creates a new list model with the specified options.
227+// The list starts with no items selected and requires SetItems to be called
228+// or items to be provided via WithItems option.
229+func New(opts ...listOptions) ListModel {
230+ t := styles.CurrentTheme()
231+
232+ m := &model{
233+ help: help.New(),
234+ keyMap: DefaultKeyMap(),
235+ allItems: []util.Model{},
236+ filteredItems: []util.Model{},
237+ renderState: newRenderState(),
238+ gapSize: DefaultGapSize,
239+ padding: []int{},
240+ selectionState: selectionState{selectedIndex: NoSelection},
241+ filterPlaceholder: "Type to filter...",
242+ inputStyle: t.S().Base.Padding(0, 1, 1, 1),
243+ isFocused: true,
244+ }
245+ for _, opt := range opts {
246+ opt(m)
247+ }
248+
249+ if m.filterable && !m.hideFilterInput {
250+ t := styles.CurrentTheme()
251+ ti := textinput.New()
252+ ti.Placeholder = m.filterPlaceholder
253+ ti.SetVirtualCursor(false)
254+ ti.Focus()
255+ ti.SetStyles(t.S().TextInput)
256+ m.input = ti
257+
258+ // disable j,k movements
259+ m.keyMap.NDown.SetEnabled(false)
260+ m.keyMap.NUp.SetEnabled(false)
261+ }
262+ return m
263+}
264+
265+// Init initializes the list component and sets up the initial items.
266+// This is called automatically by the Bubble Tea framework.
267+func (m *model) Init() tea.Cmd {
268+ return m.SetItems(m.filteredItems)
269+}
270+
271+// Update handles incoming messages and updates the list state accordingly.
272+// It processes keyboard input, animation messages, and forwards other messages
273+// to the currently selected item.
274+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
275+ switch msg := msg.(type) {
276+ case tea.KeyPressMsg:
277+ return m.handleKeyPress(msg)
278+ case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
279+ return m.handleAnimationMsg(msg)
280+ }
281+ if m.selectionState.isValidIndex(len(m.filteredItems)) {
282+ return m.updateSelectedItem(msg)
283+ }
284+
285+ return m, nil
286+}
287+
288+// View renders the list to a string for display.
289+// Returns empty string if the list has no dimensions.
290+// Triggers re-rendering if needed before returning content.
291+func (m *model) View() tea.View {
292+ if m.viewState.height == 0 || m.viewState.width == 0 {
293+ return tea.NewView("") // No content to display
294+ }
295+ if m.renderState.needsRerender {
296+ m.renderVisible()
297+ }
298+
299+ content := lipgloss.NewStyle().
300+ Padding(m.padding...).
301+ Height(m.viewState.height).
302+ Render(m.viewState.content)
303+
304+ if m.filterable && !m.hideFilterInput {
305+ content = lipgloss.JoinVertical(
306+ lipgloss.Left,
307+ m.inputStyle.Render(m.input.View()),
308+ content,
309+ )
310+ }
311+ view := tea.NewView(content)
312+ if m.filterable && !m.hideFilterInput {
313+ view.SetCursor(m.input.Cursor())
314+ }
315+ return view
316+}
317+
318+// handleKeyPress processes keyboard input for list navigation.
319+// Supports scrolling, item selection, and navigation to top/bottom.
320+func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
321+ switch {
322+ case key.Matches(msg, m.keyMap.Down) || key.Matches(msg, m.keyMap.NDown):
323+ m.scrollDown(1)
324+ case key.Matches(msg, m.keyMap.Up) || key.Matches(msg, m.keyMap.NUp):
325+ m.scrollUp(1)
326+ case key.Matches(msg, m.keyMap.DownOneItem):
327+ return m, m.selectNextItem()
328+ case key.Matches(msg, m.keyMap.UpOneItem):
329+ return m, m.selectPreviousItem()
330+ case key.Matches(msg, m.keyMap.HalfPageDown):
331+ m.scrollDown(m.listHeight() / 2)
332+ case key.Matches(msg, m.keyMap.HalfPageUp):
333+ m.scrollUp(m.listHeight() / 2)
334+ case key.Matches(msg, m.keyMap.Home):
335+ return m, m.goToTop()
336+ case key.Matches(msg, m.keyMap.End):
337+ return m, m.goToBottom()
338+ default:
339+ if !m.filterable || m.hideFilterInput {
340+ return m, nil // Ignore other keys if not filterable or input is hidden
341+ }
342+ var cmds []tea.Cmd
343+ u, cmd := m.input.Update(msg)
344+ m.input = u
345+ cmds = append(cmds, cmd)
346+ if m.currentSearch != m.input.Value() {
347+ cmd = m.Filter(m.input.Value())
348+ cmds = append(cmds, cmd)
349+ }
350+ m.currentSearch = m.input.Value()
351+ return m, tea.Batch(cmds...)
352+ }
353+ return m, nil
354+}
355+
356+// handleAnimationMsg forwards animation messages to items that support animation.
357+// Only items implementing HasAnim and currently spinning receive these messages.
358+func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
359+ var cmds []tea.Cmd
360+ for inx, item := range m.filteredItems {
361+ if i, ok := item.(HasAnim); ok && i.Spinning() {
362+ updated, cmd := i.Update(msg)
363+ cmds = append(cmds, cmd)
364+ if u, ok := updated.(util.Model); ok {
365+ m.UpdateItem(inx, u)
366+ }
367+ }
368+ }
369+ return m, tea.Batch(cmds...)
370+}
371+
372+// updateSelectedItem forwards messages to the currently selected item.
373+// This allows the selected item to handle its own input and state changes.
374+func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
375+ var cmds []tea.Cmd
376+ u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg)
377+ cmds = append(cmds, cmd)
378+ if updated, ok := u.(util.Model); ok {
379+ m.UpdateItem(m.selectionState.selectedIndex, updated)
380+ }
381+ return m, tea.Batch(cmds...)
382+}
383+
384+// scrollDown scrolls the list down by the specified amount.
385+// Direction is automatically adjusted based on reverse mode.
386+func (m *model) scrollDown(amount int) {
387+ if m.viewState.reverse {
388+ m.decreaseOffset(amount)
389+ } else {
390+ m.increaseOffset(amount)
391+ }
392+}
393+
394+// scrollUp scrolls the list up by the specified amount.
395+// Direction is automatically adjusted based on reverse mode.
396+func (m *model) scrollUp(amount int) {
397+ if m.viewState.reverse {
398+ m.increaseOffset(amount)
399+ } else {
400+ m.decreaseOffset(amount)
401+ }
402+}
403+
404+// Items returns a copy of all items in the list.
405+func (m *model) Items() []util.Model {
406+ return m.filteredItems
407+}
408+
409+// renderVisible determines which rendering strategy to use and triggers rendering.
410+// Uses forward rendering for normal mode and reverse rendering for reverse mode.
411+func (m *model) renderVisible() {
412+ if m.viewState.reverse {
413+ m.renderVisibleReverse()
414+ } else {
415+ m.renderVisibleForward()
416+ }
417+}
418+
419+// renderVisibleForward renders items from top to bottom (normal mode).
420+// Only renders items that are currently visible or near the viewport.
421+func (m *model) renderVisibleForward() {
422+ renderer := &forwardRenderer{
423+ model: m,
424+ start: 0,
425+ cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd
426+ items: m.filteredItems,
427+ realIdx: m.renderState.lastIndex,
428+ }
429+
430+ if m.renderState.lastIndex > NotRendered {
431+ renderer.items = m.filteredItems[m.renderState.lastIndex+1:]
432+ renderer.start = len(m.renderState.lines)
433+ }
434+
435+ renderer.render()
436+ m.finalizeRender()
437+}
438+
439+// renderVisibleReverse renders items from bottom to top (reverse mode).
440+// Used when new items should appear at the bottom (like chat messages).
441+func (m *model) renderVisibleReverse() {
442+ renderer := &reverseRenderer{
443+ model: m,
444+ start: 0,
445+ cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2,
446+ items: m.filteredItems,
447+ realIdx: m.renderState.lastIndex,
448+ }
449+
450+ if m.renderState.lastIndex > NotRendered {
451+ renderer.items = m.filteredItems[:m.renderState.lastIndex]
452+ renderer.start = len(m.renderState.lines)
453+ } else {
454+ m.renderState.lastIndex = len(m.filteredItems)
455+ renderer.realIdx = len(m.filteredItems)
456+ }
457+
458+ renderer.render()
459+ m.finalizeRender()
460+}
461+
462+// finalizeRender completes the rendering process by updating scroll bounds and content.
463+func (m *model) finalizeRender() {
464+ m.renderState.needsRerender = false
465+ if m.renderState.finalHeight > NoFinalHeight {
466+ m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
467+ }
468+ m.updateContent()
469+}
470+
471+// updateContent extracts the visible portion of rendered content for display.
472+// Handles both normal and reverse rendering modes.
473+func (m *model) updateContent() {
474+ maxHeight := min(m.listHeight(), len(m.renderState.lines))
475+ if m.viewState.offset >= len(m.renderState.lines) {
476+ m.viewState.content = ""
477+ return
478+ }
479+
480+ if m.viewState.reverse {
481+ end := len(m.renderState.lines) - m.viewState.offset
482+ start := max(0, end-maxHeight)
483+ m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
484+ } else {
485+ endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines))
486+ m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n")
487+ }
488+}
489+
490+// forwardRenderer handles rendering items from top to bottom.
491+// It builds up the rendered content incrementally, caching results for performance.
492+type forwardRenderer struct {
493+ model *model // Reference to the parent list model
494+ start int // Current line position in the overall content
495+ cutoff int // Line position where we can stop rendering
496+ items []util.Model // Items to render (may be a subset)
497+ realIdx int // Real index in the full item list
498+}
499+
500+// render processes items in forward order, building up the rendered content.
501+func (r *forwardRenderer) render() {
502+ for _, item := range r.items {
503+ r.realIdx++
504+ if r.start > r.cutoff {
505+ break
506+ }
507+
508+ itemLines := r.getOrRenderItem(item)
509+ if r.realIdx == len(r.model.filteredItems)-1 {
510+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
511+ }
512+
513+ r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
514+ r.model.renderState.lastIndex = r.realIdx
515+ r.start += len(itemLines)
516+ }
517+}
518+
519+// getOrRenderItem retrieves cached content or renders the item if not cached.
520+func (r *forwardRenderer) getOrRenderItem(item util.Model) []string {
521+ if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
522+ return cachedContent.lines
523+ }
524+
525+ itemLines := r.renderItemLines(item)
526+ r.model.renderState.items[r.realIdx] = renderedItem{
527+ lines: itemLines,
528+ start: r.start,
529+ height: len(itemLines),
530+ }
531+ return itemLines
532+}
533+
534+// renderItemLines converts an item to its string representation with gaps.
535+func (r *forwardRenderer) renderItemLines(item util.Model) []string {
536+ return r.model.getItemLines(item)
537+}
538+
539+// reverseRenderer handles rendering items from bottom to top.
540+// Used in reverse mode where new items appear at the bottom.
541+type reverseRenderer struct {
542+ model *model // Reference to the parent list model
543+ start int // Current line position in the overall content
544+ cutoff int // Line position where we can stop rendering
545+ items []util.Model // Items to render (may be a subset)
546+ realIdx int // Real index in the full item list
547+}
548+
549+// render processes items in reverse order, prepending to the rendered content.
550+func (r *reverseRenderer) render() {
551+ for i := len(r.items) - 1; i >= 0; i-- {
552+ r.realIdx--
553+ if r.start > r.cutoff {
554+ break
555+ }
556+
557+ itemLines := r.getOrRenderItem(r.items[i])
558+ if r.realIdx == 0 {
559+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
560+ }
561+
562+ r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
563+ r.model.renderState.lastIndex = r.realIdx
564+ r.start += len(itemLines)
565+ }
566+}
567+
568+// getOrRenderItem retrieves cached content or renders the item if not cached.
569+func (r *reverseRenderer) getOrRenderItem(item util.Model) []string {
570+ if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
571+ return cachedContent.lines
572+ }
573+
574+ itemLines := r.renderItemLines(item)
575+ r.model.renderState.items[r.realIdx] = renderedItem{
576+ lines: itemLines,
577+ start: r.start,
578+ height: len(itemLines),
579+ }
580+ return itemLines
581+}
582+
583+// renderItemLines converts an item to its string representation with gaps.
584+func (r *reverseRenderer) renderItemLines(item util.Model) []string {
585+ return r.model.getItemLines(item)
586+}
587+
588+// selectPreviousItem moves selection to the previous item in the list.
589+// Handles focus management and ensures the selected item remains visible.
590+// Skips section headers during navigation.
591+func (m *model) selectPreviousItem() tea.Cmd {
592+ if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation {
593+ // If at the beginning and wrapping is enabled, go to the last item
594+ return m.goToBottom()
595+ }
596+ if m.selectionState.selectedIndex <= 0 {
597+ return nil
598+ }
599+
600+ cmds := []tea.Cmd{m.blurSelected()}
601+ m.selectionState.selectedIndex--
602+
603+ // Skip section headers
604+ for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) {
605+ m.selectionState.selectedIndex--
606+ }
607+
608+ // If we went past the beginning, stay at the first non-header item
609+ if m.selectionState.selectedIndex <= 0 {
610+ cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed
611+ return tea.Batch(cmds...)
612+ }
613+
614+ cmds = append(cmds, m.focusSelected())
615+ m.ensureSelectedItemVisible()
616+ return tea.Batch(cmds...)
617+}
618+
619+// selectNextItem moves selection to the next item in the list.
620+// Handles focus management and ensures the selected item remains visible.
621+// Skips section headers during navigation.
622+func (m *model) selectNextItem() tea.Cmd {
623+ if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation {
624+ // If at the end and wrapping is enabled, go to the first item
625+ return m.goToTop()
626+ }
627+ if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
628+ return nil
629+ }
630+
631+ cmds := []tea.Cmd{m.blurSelected()}
632+ m.selectionState.selectedIndex++
633+
634+ // Skip section headers
635+ for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) {
636+ m.selectionState.selectedIndex++
637+ }
638+
639+ // If we went past the end, stay at the last non-header item
640+ if m.selectionState.selectedIndex >= len(m.filteredItems) {
641+ m.selectionState.selectedIndex = m.findLastSelectableItem()
642+ }
643+
644+ cmds = append(cmds, m.focusSelected())
645+ m.ensureSelectedItemVisible()
646+ return tea.Batch(cmds...)
647+}
648+
649+// isSectionHeader checks if the item at the given index is a section header.
650+func (m *model) isSectionHeader(index int) bool {
651+ if index < 0 || index >= len(m.filteredItems) {
652+ return false
653+ }
654+ if header, ok := m.filteredItems[index].(SectionHeader); ok {
655+ return header.IsSectionHeader()
656+ }
657+ return false
658+}
659+
660+// findFirstSelectableItem finds the first item that is not a section header.
661+func (m *model) findFirstSelectableItem() int {
662+ for i := range m.filteredItems {
663+ if !m.isSectionHeader(i) {
664+ return i
665+ }
666+ }
667+ return NoSelection
668+}
669+
670+// findLastSelectableItem finds the last item that is not a section header.
671+func (m *model) findLastSelectableItem() int {
672+ for i := len(m.filteredItems) - 1; i >= 0; i-- {
673+ if !m.isSectionHeader(i) {
674+ return i
675+ }
676+ }
677+ return NoSelection
678+}
679+
680+// ensureSelectedItemVisible scrolls the list to make the selected item visible.
681+// Uses different strategies for forward and reverse rendering modes.
682+func (m *model) ensureSelectedItemVisible() {
683+ cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex]
684+ if !ok {
685+ m.renderState.needsRerender = true
686+ return
687+ }
688+
689+ if m.viewState.reverse {
690+ m.ensureVisibleReverse(cachedItem)
691+ } else {
692+ m.ensureVisibleForward(cachedItem)
693+ }
694+ m.renderState.needsRerender = true
695+}
696+
697+// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
698+// Handles both large items (taller than viewport) and normal items.
699+func (m *model) ensureVisibleForward(cachedItem renderedItem) {
700+ if cachedItem.height >= m.listHeight() {
701+ if m.selectionState.selectedIndex > 0 {
702+ changeNeeded := m.viewState.offset - cachedItem.start
703+ m.decreaseOffset(changeNeeded)
704+ } else {
705+ changeNeeded := cachedItem.start - m.viewState.offset
706+ m.increaseOffset(changeNeeded)
707+ }
708+ return
709+ }
710+
711+ if cachedItem.start < m.viewState.offset {
712+ changeNeeded := m.viewState.offset - cachedItem.start
713+ m.decreaseOffset(changeNeeded)
714+ } else {
715+ end := cachedItem.start + cachedItem.height
716+ if end > m.viewState.offset+m.listHeight() {
717+ changeNeeded := end - (m.viewState.offset + m.listHeight())
718+ m.increaseOffset(changeNeeded)
719+ }
720+ }
721+}
722+
723+// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
724+// Handles both large items (taller than viewport) and normal items.
725+func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
726+ if cachedItem.height >= m.listHeight() {
727+ if m.selectionState.selectedIndex < len(m.filteredItems)-1 {
728+ changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
729+ m.decreaseOffset(changeNeeded)
730+ } else {
731+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
732+ m.increaseOffset(changeNeeded)
733+ }
734+ return
735+ }
736+
737+ if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
738+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
739+ m.increaseOffset(changeNeeded)
740+ } else if cachedItem.start < m.viewState.offset {
741+ changeNeeded := m.viewState.offset - cachedItem.start
742+ m.decreaseOffset(changeNeeded)
743+ }
744+}
745+
746+// goToBottom switches to reverse mode and selects the last selectable item.
747+// Commonly used for chat-like interfaces where new content appears at the bottom.
748+// Skips section headers when selecting the last item.
749+func (m *model) goToBottom() tea.Cmd {
750+ cmds := []tea.Cmd{m.blurSelected()}
751+ m.viewState.reverse = true
752+ if m.isFocused {
753+ m.selectionState.selectedIndex = m.findLastSelectableItem()
754+ cmds = append(cmds, m.focusSelected())
755+ }
756+ m.ResetView()
757+ return tea.Batch(cmds...)
758+}
759+
760+// goToTop switches to forward mode and selects the first selectable item.
761+// Standard behavior for most list interfaces.
762+// Skips section headers when selecting the first item.
763+func (m *model) goToTop() tea.Cmd {
764+ cmds := []tea.Cmd{m.blurSelected()}
765+ m.viewState.reverse = false
766+ m.selectionState.selectedIndex = m.findFirstSelectableItem()
767+ cmds = append(cmds, m.focusSelected())
768+ m.ResetView()
769+ return tea.Batch(cmds...)
770+}
771+
772+// ResetView clears all cached rendering data and resets scroll position.
773+// Forces a complete re-render on the next View() call.
774+func (m *model) ResetView() {
775+ m.renderState.reset()
776+ m.viewState.offset = 0
777+}
778+
779+// focusSelected gives focus to the currently selected item if it supports focus.
780+// Triggers a re-render of the item to show its focused state.
781+func (m *model) focusSelected() tea.Cmd {
782+ if !m.isFocused {
783+ return nil // No focus change if the list is not focused
784+ }
785+ if !m.selectionState.isValidIndex(len(m.filteredItems)) {
786+ return nil
787+ }
788+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
789+ cmd := i.Focus()
790+ m.rerenderItem(m.selectionState.selectedIndex)
791+ return cmd
792+ }
793+ return nil
794+}
795+
796+// blurSelected removes focus from the currently selected item if it supports focus.
797+// Triggers a re-render of the item to show its unfocused state.
798+func (m *model) blurSelected() tea.Cmd {
799+ if !m.selectionState.isValidIndex(len(m.filteredItems)) {
800+ return nil
801+ }
802+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
803+ cmd := i.Blur()
804+ m.rerenderItem(m.selectionState.selectedIndex)
805+ return cmd
806+ }
807+ return nil
808+}
809+
810+// rerenderItem updates the cached rendering of a specific item.
811+// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
812+// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
813+func (m *model) rerenderItem(inx int) {
814+ if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 {
815+ return
816+ }
817+
818+ cachedItem, ok := m.renderState.items[inx]
819+ if !ok {
820+ return
821+ }
822+
823+ rerenderedLines := m.getItemLines(m.filteredItems[inx])
824+ if slices.Equal(cachedItem.lines, rerenderedLines) {
825+ return
826+ }
827+
828+ m.updateRenderedLines(cachedItem, rerenderedLines)
829+ m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
830+ m.updateCachedItem(inx, cachedItem, rerenderedLines)
831+ m.renderState.needsRerender = true
832+}
833+
834+// getItemLines converts an item to its rendered lines, including any gap spacing.
835+// Handles section headers with special styling.
836+func (m *model) getItemLines(item util.Model) []string {
837+ var itemLines []string
838+
839+ itemLines = strings.Split(item.View().String(), "\n")
840+
841+ if m.gapSize > 0 {
842+ gap := make([]string, m.gapSize)
843+ itemLines = append(itemLines, gap...)
844+ }
845+ return itemLines
846+}
847+
848+// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
849+func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
850+ start, end := m.getItemBounds(cachedItem)
851+ totalLines := len(m.renderState.lines)
852+
853+ if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines {
854+ m.renderState.lines = slices.Delete(m.renderState.lines, start, end)
855+ m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...)
856+ }
857+}
858+
859+// getItemBounds calculates the start and end line positions for an item.
860+// Handles both forward and reverse rendering modes.
861+func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) {
862+ start = cachedItem.start
863+ end = start + cachedItem.height
864+
865+ if m.viewState.reverse {
866+ totalLines := len(m.renderState.lines)
867+ end = totalLines - cachedItem.start
868+ start = end - cachedItem.height
869+ }
870+ return start, end
871+}
872+
873+// updateItemPositions recalculates positions for items after the changed item.
874+// This is necessary when an item's height changes, affecting subsequent items.
875+func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) {
876+ if cachedItem.height == newHeight {
877+ return
878+ }
879+
880+ if inx == len(m.filteredItems)-1 {
881+ m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
882+ }
883+
884+ currentStart := cachedItem.start + newHeight
885+ if m.viewState.reverse {
886+ m.updatePositionsReverse(inx, currentStart)
887+ } else {
888+ m.updatePositionsForward(inx, currentStart)
889+ }
890+}
891+
892+// updatePositionsForward updates positions for items after the changed item in forward mode.
893+func (m *model) updatePositionsForward(inx int, currentStart int) {
894+ for i := inx + 1; i < len(m.filteredItems); i++ {
895+ if existing, ok := m.renderState.items[i]; ok {
896+ existing.start = currentStart
897+ currentStart += existing.height
898+ m.renderState.items[i] = existing
899+ } else {
900+ break
901+ }
902+ }
903+}
904+
905+// updatePositionsReverse updates positions for items before the changed item in reverse mode.
906+func (m *model) updatePositionsReverse(inx int, currentStart int) {
907+ for i := inx - 1; i >= 0; i-- {
908+ if existing, ok := m.renderState.items[i]; ok {
909+ existing.start = currentStart
910+ currentStart += existing.height
911+ m.renderState.items[i] = existing
912+ } else {
913+ break
914+ }
915+ }
916+}
917+
918+// updateCachedItem updates the cached rendering information for a specific item.
919+func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
920+ m.renderState.items[inx] = renderedItem{
921+ lines: newLines,
922+ start: cachedItem.start,
923+ height: len(newLines),
924+ }
925+}
926+
927+// increaseOffset scrolls the list down by increasing the offset.
928+// Respects the final height limit to prevent scrolling past the end.
929+func (m *model) increaseOffset(n int) {
930+ if m.renderState.finalHeight > NoFinalHeight {
931+ if m.viewState.offset < m.renderState.finalHeight {
932+ m.viewState.offset += n
933+ if m.viewState.offset > m.renderState.finalHeight {
934+ m.viewState.offset = m.renderState.finalHeight
935+ }
936+ m.renderState.needsRerender = true
937+ }
938+ } else {
939+ m.viewState.offset += n
940+ m.renderState.needsRerender = true
941+ }
942+}
943+
944+// decreaseOffset scrolls the list up by decreasing the offset.
945+// Prevents scrolling above the beginning of the list.
946+func (m *model) decreaseOffset(n int) {
947+ if m.viewState.offset > 0 {
948+ m.viewState.offset -= n
949+ if m.viewState.offset < 0 {
950+ m.viewState.offset = 0
951+ }
952+ m.renderState.needsRerender = true
953+ }
954+}
955+
956+// UpdateItem replaces an item at the specified index with a new item.
957+// Handles focus management and triggers re-rendering as needed.
958+func (m *model) UpdateItem(inx int, item util.Model) {
959+ if inx < 0 || inx >= len(m.filteredItems) {
960+ return
961+ }
962+ m.filteredItems[inx] = item
963+ if m.selectionState.selectedIndex == inx {
964+ m.focusSelected()
965+ }
966+ m.setItemSize(inx)
967+ m.rerenderItem(inx)
968+ m.renderState.needsRerender = true
969+}
970+
971+// GetSize returns the current dimensions of the list.
972+func (m *model) GetSize() (int, int) {
973+ return m.viewState.width, m.viewState.height
974+}
975+
976+// SetSize updates the list dimensions and triggers a complete re-render.
977+// Also updates the size of all items that support sizing.
978+func (m *model) SetSize(width int, height int) tea.Cmd {
979+ if m.filterable && !m.hideFilterInput {
980+ height -= 2 // adjust for input field height and border
981+ }
982+
983+ if m.viewState.width == width && m.viewState.height == height {
984+ return nil
985+ }
986+ if m.viewState.height != height {
987+ m.renderState.finalHeight = NoFinalHeight
988+ m.viewState.height = height
989+ }
990+ m.viewState.width = width
991+ m.ResetView()
992+ if m.filterable && !m.hideFilterInput {
993+ m.input.SetWidth(m.getItemWidth() - 5)
994+ }
995+ return m.setAllItemsSize()
996+}
997+
998+// getItemWidth calculates the available width for items, accounting for padding.
999+func (m *model) getItemWidth() int {
1000+ width := m.viewState.width
1001+ switch len(m.padding) {
1002+ case 1:
1003+ width -= m.padding[0] * 2
1004+ case 2, 3:
1005+ width -= m.padding[1] * 2
1006+ case 4:
1007+ width -= m.padding[1] + m.padding[3]
1008+ }
1009+ return max(0, width)
1010+}
1011+
1012+// setItemSize updates the size of a specific item if it supports sizing.
1013+func (m *model) setItemSize(inx int) tea.Cmd {
1014+ if inx < 0 || inx >= len(m.filteredItems) {
1015+ return nil
1016+ }
1017+ if i, ok := m.filteredItems[inx].(layout.Sizeable); ok {
1018+ return i.SetSize(m.getItemWidth(), 0)
1019+ }
1020+ return nil
1021+}
1022+
1023+// setAllItemsSize updates the size of all items that support sizing.
1024+func (m *model) setAllItemsSize() tea.Cmd {
1025+ var cmds []tea.Cmd
1026+ for i := range m.filteredItems {
1027+ if cmd := m.setItemSize(i); cmd != nil {
1028+ cmds = append(cmds, cmd)
1029+ }
1030+ }
1031+ return tea.Batch(cmds...)
1032+}
1033+
1034+// listHeight calculates the available height for list content, accounting for padding.
1035+func (m *model) listHeight() int {
1036+ height := m.viewState.height
1037+ switch len(m.padding) {
1038+ case 1:
1039+ height -= m.padding[0] * 2
1040+ case 2:
1041+ height -= m.padding[0] * 2
1042+ case 3, 4:
1043+ height -= m.padding[0] + m.padding[2]
1044+ }
1045+ if m.filterable && !m.hideFilterInput {
1046+ height -= lipgloss.Height(m.inputStyle.Render("dummy"))
1047+ }
1048+ return max(0, height)
1049+}
1050+
1051+// AppendItem adds a new item to the end of the list.
1052+// Automatically switches to reverse mode and scrolls to show the new item.
1053+func (m *model) AppendItem(item util.Model) tea.Cmd {
1054+ cmds := []tea.Cmd{
1055+ item.Init(),
1056+ }
1057+ m.allItems = append(m.allItems, item)
1058+ m.filteredItems = m.allItems
1059+ cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1))
1060+ cmds = append(cmds, m.goToBottom())
1061+ m.renderState.needsRerender = true
1062+ return tea.Batch(cmds...)
1063+}
1064+
1065+// DeleteItem removes an item at the specified index.
1066+// Adjusts selection if necessary and triggers a complete re-render.
1067+func (m *model) DeleteItem(i int) {
1068+ if i < 0 || i >= len(m.filteredItems) {
1069+ return
1070+ }
1071+ m.allItems = slices.Delete(m.allItems, i, i+1)
1072+ delete(m.renderState.items, i)
1073+ m.filteredItems = m.allItems
1074+
1075+ if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
1076+ m.selectionState.selectedIndex--
1077+ } else if m.selectionState.selectedIndex > i {
1078+ m.selectionState.selectedIndex--
1079+ }
1080+
1081+ m.ResetView()
1082+ m.renderState.needsRerender = true
1083+}
1084+
1085+// PrependItem adds a new item to the beginning of the list.
1086+// Adjusts cached positions and selection index, then switches to forward mode.
1087+func (m *model) PrependItem(item util.Model) tea.Cmd {
1088+ cmds := []tea.Cmd{item.Init()}
1089+ m.allItems = append([]util.Model{item}, m.allItems...)
1090+ m.filteredItems = m.allItems
1091+
1092+ // Shift all cached item indices by 1
1093+ newItems := make(map[int]renderedItem, len(m.renderState.items))
1094+ for k, v := range m.renderState.items {
1095+ newItems[k+1] = v
1096+ }
1097+ m.renderState.items = newItems
1098+
1099+ if m.selectionState.selectedIndex >= 0 {
1100+ m.selectionState.selectedIndex++
1101+ }
1102+
1103+ cmds = append(cmds, m.goToTop())
1104+ cmds = append(cmds, m.setItemSize(0))
1105+ m.renderState.needsRerender = true
1106+ return tea.Batch(cmds...)
1107+}
1108+
1109+// setReverse switches between forward and reverse rendering modes.
1110+func (m *model) setReverse(reverse bool) {
1111+ if reverse {
1112+ m.goToBottom()
1113+ } else {
1114+ m.goToTop()
1115+ }
1116+}
1117+
1118+// SetItems replaces all items in the list with a new set.
1119+// Initializes all items, sets their sizes, and establishes initial selection.
1120+// Ensures the initial selection skips section headers.
1121+func (m *model) SetItems(items []util.Model) tea.Cmd {
1122+ m.allItems = items
1123+ m.filteredItems = items
1124+ cmds := []tea.Cmd{m.setAllItemsSize()}
1125+
1126+ for _, item := range m.filteredItems {
1127+ cmds = append(cmds, item.Init())
1128+ }
1129+
1130+ if len(m.filteredItems) > 0 {
1131+ if m.viewState.reverse {
1132+ m.selectionState.selectedIndex = m.findLastSelectableItem()
1133+ } else {
1134+ m.selectionState.selectedIndex = m.findFirstSelectableItem()
1135+ }
1136+ if cmd := m.focusSelected(); cmd != nil {
1137+ cmds = append(cmds, cmd)
1138+ }
1139+ } else {
1140+ m.selectionState.selectedIndex = NoSelection
1141+ }
1142+
1143+ m.ResetView()
1144+ return tea.Batch(cmds...)
1145+}
1146+
1147+// section represents a group of items under a section header.
1148+type section struct {
1149+ header SectionHeader
1150+ items []util.Model
1151+}
1152+
1153+// parseSections parses the flat item list into sections.
1154+func (m *model) parseSections() []section {
1155+ var sections []section
1156+ var currentSection *section
1157+
1158+ for _, item := range m.allItems {
1159+ if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() {
1160+ // Start a new section
1161+ if currentSection != nil {
1162+ sections = append(sections, *currentSection)
1163+ }
1164+ currentSection = &section{
1165+ header: header,
1166+ items: []util.Model{},
1167+ }
1168+ } else if currentSection != nil {
1169+ // Add item to current section
1170+ currentSection.items = append(currentSection.items, item)
1171+ } else {
1172+ // Item without a section header - create an implicit section
1173+ if len(sections) == 0 || sections[len(sections)-1].header != nil {
1174+ sections = append(sections, section{
1175+ header: nil,
1176+ items: []util.Model{item},
1177+ })
1178+ } else {
1179+ // Add to the last implicit section
1180+ sections[len(sections)-1].items = append(sections[len(sections)-1].items, item)
1181+ }
1182+ }
1183+ }
1184+
1185+ // Don't forget the last section
1186+ if currentSection != nil {
1187+ sections = append(sections, *currentSection)
1188+ }
1189+
1190+ return sections
1191+}
1192+
1193+// flattenSections converts sections back to a flat list.
1194+func (m *model) flattenSections(sections []section) []util.Model {
1195+ var result []util.Model
1196+
1197+ for _, sect := range sections {
1198+ if sect.header != nil {
1199+ result = append(result, sect.header)
1200+ }
1201+ result = append(result, sect.items...)
1202+ }
1203+
1204+ return result
1205+}
1206+
1207+func (m *model) Filter(search string) tea.Cmd {
1208+ var cmds []tea.Cmd
1209+ search = strings.TrimSpace(search)
1210+ search = strings.ToLower(search)
1211+
1212+ // Clear focus and match indexes from all items
1213+ for _, item := range m.allItems {
1214+ if i, ok := item.(layout.Focusable); ok {
1215+ cmds = append(cmds, i.Blur())
1216+ }
1217+ if i, ok := item.(HasMatchIndexes); ok {
1218+ i.MatchIndexes(make([]int, 0))
1219+ }
1220+ }
1221+
1222+ if search == "" {
1223+ cmds = append(cmds, m.SetItems(m.allItems))
1224+ return tea.Batch(cmds...)
1225+ }
1226+
1227+ // Parse items into sections
1228+ sections := m.parseSections()
1229+ var filteredSections []section
1230+
1231+ for _, sect := range sections {
1232+ filteredSection := m.filterSection(sect, search)
1233+ if filteredSection != nil {
1234+ filteredSections = append(filteredSections, *filteredSection)
1235+ }
1236+ }
1237+
1238+ // Rebuild flat list from filtered sections
1239+ m.filteredItems = m.flattenSections(filteredSections)
1240+
1241+ // Set initial selection
1242+ if len(m.filteredItems) > 0 {
1243+ if m.viewState.reverse {
1244+ slices.Reverse(m.filteredItems)
1245+ m.selectionState.selectedIndex = m.findLastSelectableItem()
1246+ } else {
1247+ m.selectionState.selectedIndex = m.findFirstSelectableItem()
1248+ }
1249+ if cmd := m.focusSelected(); cmd != nil {
1250+ cmds = append(cmds, cmd)
1251+ }
1252+ } else {
1253+ m.selectionState.selectedIndex = NoSelection
1254+ }
1255+
1256+ m.ResetView()
1257+ return tea.Batch(cmds...)
1258+}
1259+
1260+// filterSection filters items within a section and returns the section if it has matches.
1261+func (m *model) filterSection(sect section, search string) *section {
1262+ var matchedItems []util.Model
1263+ var hasHeaderMatch bool
1264+
1265+ // Check if section header itself matches
1266+ if sect.header != nil {
1267+ headerText := strings.ToLower(sect.header.View().String())
1268+ if strings.Contains(headerText, search) {
1269+ hasHeaderMatch = true
1270+ // If header matches, include all items in the section
1271+ matchedItems = sect.items
1272+ }
1273+ }
1274+
1275+ // If header didn't match, filter items within the section
1276+ if !hasHeaderMatch && len(sect.items) > 0 {
1277+ // Create words array for items in this section
1278+ words := make([]string, len(sect.items))
1279+ for i, item := range sect.items {
1280+ if f, ok := item.(HasFilterValue); ok {
1281+ words[i] = strings.ToLower(f.FilterValue())
1282+ } else {
1283+ words[i] = ""
1284+ }
1285+ }
1286+
1287+ // Find matches within this section
1288+ matches := fuzzy.Find(search, words)
1289+
1290+ // Sort matches by score but preserve relative order for equal scores
1291+ sort.SliceStable(matches, func(i, j int) bool {
1292+ return matches[i].Score > matches[j].Score
1293+ })
1294+
1295+ // Build matched items list
1296+ for _, match := range matches {
1297+ item := sect.items[match.Index]
1298+ if i, ok := item.(HasMatchIndexes); ok {
1299+ i.MatchIndexes(match.MatchedIndexes)
1300+ }
1301+ matchedItems = append(matchedItems, item)
1302+ }
1303+ }
1304+
1305+ // Return section only if it has matches
1306+ if len(matchedItems) > 0 {
1307+ return &section{
1308+ header: sect.header,
1309+ items: matchedItems,
1310+ }
1311+ }
1312+
1313+ return nil
1314+}
1315+
1316+// SelectedIndex returns the index of the currently selected item.
1317+func (m *model) SelectedIndex() int {
1318+ if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) {
1319+ return NoSelection
1320+ }
1321+ return m.selectionState.selectedIndex
1322+}
1323+
1324+// SetSelected sets the selected item by index and automatically scrolls to make it visible.
1325+// If the index is invalid or points to a section header, it finds the nearest selectable item.
1326+func (m *model) SetSelected(index int) tea.Cmd {
1327+ changeNeeded := m.selectionState.selectedIndex - index
1328+ cmds := []tea.Cmd{}
1329+ if changeNeeded < 0 {
1330+ for range -changeNeeded {
1331+ cmds = append(cmds, m.selectNextItem())
1332+ m.renderVisible()
1333+ }
1334+ } else if changeNeeded > 0 {
1335+ for range changeNeeded {
1336+ cmds = append(cmds, m.selectPreviousItem())
1337+ m.renderVisible()
1338+ }
1339+ }
1340+ return tea.Batch(cmds...)
1341+}
1342+
1343+// Blur implements ListModel.
1344+func (m *model) Blur() tea.Cmd {
1345+ m.isFocused = false
1346+ cmd := m.blurSelected()
1347+ return cmd
1348+}
1349+
1350+// Focus implements ListModel.
1351+func (m *model) Focus() tea.Cmd {
1352+ m.isFocused = true
1353+ cmd := m.focusSelected()
1354+ return cmd
1355+}
1356+
1357+// IsFocused implements ListModel.
1358+func (m *model) IsFocused() bool {
1359+ return m.isFocused
1360+}
internal/tui/components/core/status.godeleted−293
@@ -1,293 +0,0 @@
1−package core
2−
3−import (
4− "fmt"
5− "strings"
6− "time"
7−
8− tea "github.com/charmbracelet/bubbletea"
9− "github.com/charmbracelet/lipgloss"
10− "github.com/opencode-ai/opencode/internal/config"
11− "github.com/opencode-ai/opencode/internal/llm/models"
12− "github.com/opencode-ai/opencode/internal/lsp"
13− "github.com/opencode-ai/opencode/internal/lsp/protocol"
14− "github.com/opencode-ai/opencode/internal/pubsub"
15− "github.com/opencode-ai/opencode/internal/session"
16− "github.com/opencode-ai/opencode/internal/tui/components/chat"
17− "github.com/opencode-ai/opencode/internal/tui/styles"
18− "github.com/opencode-ai/opencode/internal/tui/theme"
19− "github.com/opencode-ai/opencode/internal/tui/util"
20−)
21−
22−type StatusCmp interface {
23− tea.Model
24−}
25−
26−type statusCmp struct {
27− info util.InfoMsg
28− width int
29− messageTTL time.Duration
30− lspClients map[string]*lsp.Client
31− session session.Session
32−}
33−
34−// clearMessageCmd is a command that clears status messages after a timeout
35−func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
36− return tea.Tick(ttl, func(time.Time) tea.Msg {
37− return util.ClearStatusMsg{}
38− })
39−}
40−
41−func (m statusCmp) Init() tea.Cmd {
42− return nil
43−}
44−
45−func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
46− switch msg := msg.(type) {
47− case tea.WindowSizeMsg:
48− m.width = msg.Width
49− return m, nil
50− case chat.SessionSelectedMsg:
51− m.session = msg
52− case chat.SessionClearedMsg:
53− m.session = session.Session{}
54− case pubsub.Event[session.Session]:
55− if msg.Type == pubsub.UpdatedEvent {
56− if m.session.ID == msg.Payload.ID {
57− m.session = msg.Payload
58− }
59− }
60− case util.InfoMsg:
61− m.info = msg
62− ttl := msg.TTL
63− if ttl == 0 {
64− ttl = m.messageTTL
65− }
66− return m, m.clearMessageCmd(ttl)
67− case util.ClearStatusMsg:
68− m.info = util.InfoMsg{}
69− }
70− return m, nil
71−}
72−
73−var helpWidget = ""
74−
75−// getHelpWidget returns the help widget with current theme colors
76−func getHelpWidget() string {
77− t := theme.CurrentTheme()
78− helpText := "ctrl+? help"
79−
80− return styles.Padded().
81− Background(t.TextMuted()).
82− Foreground(t.BackgroundDarker()).
83− Bold(true).
84− Render(helpText)
85−}
86−
87−func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
88− // Format tokens in human-readable format (e.g., 110K, 1.2M)
89− var formattedTokens string
90− switch {
91− case tokens >= 1_000_000:
92− formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
93− case tokens >= 1_000:
94− formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
95− default:
96− formattedTokens = fmt.Sprintf("%d", tokens)
97− }
98−
99− // Remove .0 suffix if present
100− if strings.HasSuffix(formattedTokens, ".0K") {
101− formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
102− }
103− if strings.HasSuffix(formattedTokens, ".0M") {
104− formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
105− }
106−
107− // Format cost with $ symbol and 2 decimal places
108− formattedCost := fmt.Sprintf("$%.2f", cost)
109−
110− percentage := (float64(tokens) / float64(contextWindow)) * 100
111− if percentage > 80 {
112− // add the warning icon and percentage
113− formattedTokens = fmt.Sprintf("%s(%d%%)", styles.WarningIcon, int(percentage))
114− }
115−
116− return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
117−}
118−
119−func (m statusCmp) View() string {
120− t := theme.CurrentTheme()
121− modelID := config.Get().Agents[config.AgentCoder].Model
122− model := models.SupportedModels[modelID]
123−
124− // Initialize the help widget
125− status := getHelpWidget()
126−
127− tokenInfoWidth := 0
128− if m.session.ID != "" {
129− totalTokens := m.session.PromptTokens + m.session.CompletionTokens
130− tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost)
131− tokensStyle := styles.Padded().
132− Background(t.Text()).
133− Foreground(t.BackgroundSecondary())
134− percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100
135− if percentage > 80 {
136− tokensStyle = tokensStyle.Background(t.Warning())
137− }
138− tokenInfoWidth = lipgloss.Width(tokens) + 2
139− status += tokensStyle.Render(tokens)
140− }
141−
142− diagnostics := styles.Padded().
143− Background(t.BackgroundDarker()).
144− Render(m.projectDiagnostics())
145−
146− availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
147−
148− if m.info.Msg != "" {
149− infoStyle := styles.Padded().
150− Foreground(t.Background()).
151− Width(availableWidht)
152−
153− switch m.info.Type {
154− case util.InfoTypeInfo:
155− infoStyle = infoStyle.Background(t.Info())
156− case util.InfoTypeWarn:
157− infoStyle = infoStyle.Background(t.Warning())
158− case util.InfoTypeError:
159− infoStyle = infoStyle.Background(t.Error())
160− }
161−
162− infoWidth := availableWidht - 10
163− // Truncate message if it's longer than available width
164− msg := m.info.Msg
165− if len(msg) > infoWidth && infoWidth > 0 {
166− msg = msg[:infoWidth] + "..."
167− }
168− status += infoStyle.Render(msg)
169− } else {
170− status += styles.Padded().
171− Foreground(t.Text()).
172− Background(t.BackgroundSecondary()).
173− Width(availableWidht).
174− Render("")
175− }
176−
177− status += diagnostics
178− status += m.model()
179− return status
180−}
181−
182−func (m *statusCmp) projectDiagnostics() string {
183− t := theme.CurrentTheme()
184−
185− // Check if any LSP server is still initializing
186− initializing := false
187− for _, client := range m.lspClients {
188− if client.GetServerState() == lsp.StateStarting {
189− initializing = true
190− break
191− }
192− }
193−
194− // If any server is initializing, show that status
195− if initializing {
196− return lipgloss.NewStyle().
197− Background(t.BackgroundDarker()).
198− Foreground(t.Warning()).
199− Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
200− }
201−
202− errorDiagnostics := []protocol.Diagnostic{}
203− warnDiagnostics := []protocol.Diagnostic{}
204− hintDiagnostics := []protocol.Diagnostic{}
205− infoDiagnostics := []protocol.Diagnostic{}
206− for _, client := range m.lspClients {
207− for _, d := range client.GetDiagnostics() {
208− for _, diag := range d {
209− switch diag.Severity {
210− case protocol.SeverityError:
211− errorDiagnostics = append(errorDiagnostics, diag)
212− case protocol.SeverityWarning:
213− warnDiagnostics = append(warnDiagnostics, diag)
214− case protocol.SeverityHint:
215− hintDiagnostics = append(hintDiagnostics, diag)
216− case protocol.SeverityInformation:
217− infoDiagnostics = append(infoDiagnostics, diag)
218− }
219− }
220− }
221− }
222−
223− if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
224− return "No diagnostics"
225− }
226−
227− diagnostics := []string{}
228−
229− if len(errorDiagnostics) > 0 {
230− errStr := lipgloss.NewStyle().
231− Background(t.BackgroundDarker()).
232− Foreground(t.Error()).
233− Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
234− diagnostics = append(diagnostics, errStr)
235− }
236− if len(warnDiagnostics) > 0 {
237− warnStr := lipgloss.NewStyle().
238− Background(t.BackgroundDarker()).
239− Foreground(t.Warning()).
240− Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
241− diagnostics = append(diagnostics, warnStr)
242− }
243− if len(hintDiagnostics) > 0 {
244− hintStr := lipgloss.NewStyle().
245− Background(t.BackgroundDarker()).
246− Foreground(t.Text()).
247− Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
248− diagnostics = append(diagnostics, hintStr)
249− }
250− if len(infoDiagnostics) > 0 {
251− infoStr := lipgloss.NewStyle().
252− Background(t.BackgroundDarker()).
253− Foreground(t.Info()).
254− Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
255− diagnostics = append(diagnostics, infoStr)
256− }
257−
258− return strings.Join(diagnostics, " ")
259−}
260−
261−func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
262− tokensWidth := 0
263− if m.session.ID != "" {
264− tokensWidth = lipgloss.Width(tokenInfo) + 2
265− }
266− return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
267−}
268−
269−func (m statusCmp) model() string {
270− t := theme.CurrentTheme()
271−
272− cfg := config.Get()
273−
274− coder, ok := cfg.Agents[config.AgentCoder]
275− if !ok {
276− return "Unknown"
277− }
278− model := models.SupportedModels[coder.Model]
279−
280− return styles.Padded().
281− Background(t.Secondary()).
282− Foreground(t.Background()).
283− Render(model.Name)
284−}
285−
286−func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
287− helpWidget = getHelpWidget()
288−
289− return &statusCmp{
290− messageTTL: 10 * time.Second,
291− lspClients: lspClients,
292− }
293−}
internal/tui/components/core/status/keys.gonew file+55
@@ -0,0 +1,55 @@
1+package status
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Tab,
10+ Commands,
11+ Sessions,
12+ Help key.Binding
13+}
14+
15+func DefaultKeyMap(tabHelp string) KeyMap {
16+ return KeyMap{
17+ Tab: key.NewBinding(
18+ key.WithKeys("tab"),
19+ key.WithHelp("tab", tabHelp),
20+ ),
21+ Commands: key.NewBinding(
22+ key.WithKeys("ctrl+p"),
23+ key.WithHelp("ctrl+p", "commands"),
24+ ),
25+ Sessions: key.NewBinding(
26+ key.WithKeys("ctrl+s"),
27+ key.WithHelp("ctrl+s", "sessions"),
28+ ),
29+ Help: key.NewBinding(
30+ key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
31+ key.WithHelp("ctrl+?", "more"),
32+ ),
33+ }
34+}
35+
36+// FullHelp implements help.KeyMap.
37+func (k KeyMap) FullHelp() [][]key.Binding {
38+ m := [][]key.Binding{}
39+ slice := layout.KeyMapToSlice(k)
40+ for i := 0; i < len(slice); i += 4 {
41+ end := min(i+4, len(slice))
42+ m = append(m, slice[i:end])
43+ }
44+ return m
45+}
46+
47+// ShortHelp implements help.KeyMap.
48+func (k KeyMap) ShortHelp() []key.Binding {
49+ return []key.Binding{
50+ k.Tab,
51+ k.Commands,
52+ k.Sessions,
53+ k.Help,
54+ }
55+}
internal/tui/components/core/status/status.gonew file+113
@@ -0,0 +1,113 @@
1+package status
2+
3+import (
4+ "time"
5+
6+ "github.com/charmbracelet/bubbles/v2/help"
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/logging"
9+ "github.com/charmbracelet/crush/internal/pubsub"
10+ "github.com/charmbracelet/crush/internal/session"
11+ "github.com/charmbracelet/crush/internal/tui/styles"
12+ "github.com/charmbracelet/crush/internal/tui/util"
13+)
14+
15+type StatusCmp interface {
16+ util.Model
17+}
18+
19+type statusCmp struct {
20+ info util.InfoMsg
21+ width int
22+ messageTTL time.Duration
23+ session session.Session
24+ help help.Model
25+}
26+
27+// clearMessageCmd is a command that clears status messages after a timeout
28+func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
29+ return tea.Tick(ttl, func(time.Time) tea.Msg {
30+ return util.ClearStatusMsg{}
31+ })
32+}
33+
34+func (m statusCmp) Init() tea.Cmd {
35+ return nil
36+}
37+
38+func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
39+ switch msg := msg.(type) {
40+ case tea.WindowSizeMsg:
41+ m.width = msg.Width
42+ return m, nil
43+
44+ // Handle status info
45+ case util.InfoMsg:
46+ m.info = msg
47+ ttl := msg.TTL
48+ if ttl == 0 {
49+ ttl = m.messageTTL
50+ }
51+ return m, m.clearMessageCmd(ttl)
52+ case util.ClearStatusMsg:
53+ m.info = util.InfoMsg{}
54+
55+ // Handle persistent logs
56+ case pubsub.Event[logging.LogMessage]:
57+ if msg.Payload.Persist {
58+ switch msg.Payload.Level {
59+ case "error":
60+ m.info = util.InfoMsg{
61+ Type: util.InfoTypeError,
62+ Msg: msg.Payload.Message,
63+ TTL: msg.Payload.PersistTime,
64+ }
65+ case "info":
66+ m.info = util.InfoMsg{
67+ Type: util.InfoTypeInfo,
68+ Msg: msg.Payload.Message,
69+ TTL: msg.Payload.PersistTime,
70+ }
71+ case "warn":
72+ m.info = util.InfoMsg{
73+ Type: util.InfoTypeWarn,
74+ Msg: msg.Payload.Message,
75+ TTL: msg.Payload.PersistTime,
76+ }
77+ default:
78+ m.info = util.InfoMsg{
79+ Type: util.InfoTypeInfo,
80+ Msg: msg.Payload.Message,
81+ TTL: msg.Payload.PersistTime,
82+ }
83+ }
84+ }
85+ }
86+ return m, nil
87+}
88+
89+func (m statusCmp) View() tea.View {
90+ t := styles.CurrentTheme()
91+ status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
92+ if m.info.Msg != "" {
93+ switch m.info.Type {
94+ case util.InfoTypeError:
95+ status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg)
96+ case util.InfoTypeWarn:
97+ status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg)
98+ default:
99+ status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg)
100+ }
101+ }
102+ return tea.NewView(status)
103+}
104+
105+func NewStatusCmp() StatusCmp {
106+ t := styles.CurrentTheme()
107+ help := help.New()
108+ help.Styles = t.S().Help
109+ return &statusCmp{
110+ messageTTL: 10 * time.Second,
111+ help: help,
112+ }
113+}
internal/tui/components/dialog/arguments.godeleted−257
@@ -1,257 +0,0 @@
1−package dialog
2−
3−import (
4− "fmt"
5− "github.com/charmbracelet/bubbles/key"
6− "github.com/charmbracelet/bubbles/textinput"
7− tea "github.com/charmbracelet/bubbletea"
8− "github.com/charmbracelet/lipgloss"
9−
10− "github.com/opencode-ai/opencode/internal/tui/styles"
11− "github.com/opencode-ai/opencode/internal/tui/theme"
12− "github.com/opencode-ai/opencode/internal/tui/util"
13−)
14−
15−type argumentsDialogKeyMap struct {
16− Enter key.Binding
17− Escape key.Binding
18−}
19−
20−// ShortHelp implements key.Map.
21−func (k argumentsDialogKeyMap) ShortHelp() []key.Binding {
22− return []key.Binding{
23− key.NewBinding(
24− key.WithKeys("enter"),
25− key.WithHelp("enter", "confirm"),
26− ),
27− key.NewBinding(
28− key.WithKeys("esc"),
29− key.WithHelp("esc", "cancel"),
30− ),
31− }
32−}
33−
34−// FullHelp implements key.Map.
35−func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
36− return [][]key.Binding{k.ShortHelp()}
37−}
38−
39−// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
40−type ShowMultiArgumentsDialogMsg struct {
41− CommandID string
42− Content string
43− ArgNames []string
44−}
45−
46−// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
47−type CloseMultiArgumentsDialogMsg struct {
48− Submit bool
49− CommandID string
50− Content string
51− Args map[string]string
52−}
53−
54−// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
55−type MultiArgumentsDialogCmp struct {
56− width, height int
57− inputs []textinput.Model
58− focusIndex int
59− keys argumentsDialogKeyMap
60− commandID string
61− content string
62− argNames []string
63−}
64−
65−// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
66−func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
67− t := theme.CurrentTheme()
68− inputs := make([]textinput.Model, len(argNames))
69−
70− for i, name := range argNames {
71− ti := textinput.New()
72− ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
73− ti.Width = 40
74− ti.Prompt = ""
75− ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
76− ti.PromptStyle = ti.PromptStyle.Background(t.Background())
77− ti.TextStyle = ti.TextStyle.Background(t.Background())
78−
79− // Only focus the first input initially
80− if i == 0 {
81− ti.Focus()
82− ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
83− ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
84− } else {
85− ti.Blur()
86− }
87−
88− inputs[i] = ti
89− }
90−
91− return MultiArgumentsDialogCmp{
92− inputs: inputs,
93− keys: argumentsDialogKeyMap{},
94− commandID: commandID,
95− content: content,
96− argNames: argNames,
97− focusIndex: 0,
98− }
99−}
100−
101−// Init implements tea.Model.
102−func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
103− // Make sure only the first input is focused
104− for i := range m.inputs {
105− if i == 0 {
106− m.inputs[i].Focus()
107− } else {
108− m.inputs[i].Blur()
109− }
110− }
111−
112− return textinput.Blink
113−}
114−
115−// Update implements tea.Model.
116−func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117− var cmds []tea.Cmd
118− t := theme.CurrentTheme()
119−
120− switch msg := msg.(type) {
121− case tea.KeyMsg:
122− switch {
123− case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
124− return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
125− Submit: false,
126− CommandID: m.commandID,
127− Content: m.content,
128− Args: nil,
129− })
130− case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
131− // If we're on the last input, submit the form
132− if m.focusIndex == len(m.inputs)-1 {
133− args := make(map[string]string)
134− for i, name := range m.argNames {
135− args[name] = m.inputs[i].Value()
136− }
137− return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
138− Submit: true,
139− CommandID: m.commandID,
140− Content: m.content,
141− Args: args,
142− })
143− }
144− // Otherwise, move to the next input
145− m.inputs[m.focusIndex].Blur()
146− m.focusIndex++
147− m.inputs[m.focusIndex].Focus()
148− m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
149− m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
150− case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
151− // Move to the next input
152− m.inputs[m.focusIndex].Blur()
153− m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
154− m.inputs[m.focusIndex].Focus()
155− m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
156− m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
157− case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
158− // Move to the previous input
159− m.inputs[m.focusIndex].Blur()
160− m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
161− m.inputs[m.focusIndex].Focus()
162− m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
163− m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
164− }
165− case tea.WindowSizeMsg:
166− m.width = msg.Width
167− m.height = msg.Height
168− }
169−
170− // Update the focused input
171− var cmd tea.Cmd
172− m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
173− cmds = append(cmds, cmd)
174−
175− return m, tea.Batch(cmds...)
176−}
177−
178−// View implements tea.Model.
179−func (m MultiArgumentsDialogCmp) View() string {
180− t := theme.CurrentTheme()
181− baseStyle := styles.BaseStyle()
182−
183− // Calculate width needed for content
184− maxWidth := 60 // Width for explanation text
185−
186− title := lipgloss.NewStyle().
187− Foreground(t.Primary()).
188− Bold(true).
189− Width(maxWidth).
190− Padding(0, 1).
191− Background(t.Background()).
192− Render("Command Arguments")
193−
194− explanation := lipgloss.NewStyle().
195− Foreground(t.Text()).
196− Width(maxWidth).
197− Padding(0, 1).
198− Background(t.Background()).
199− Render("This command requires multiple arguments. Please enter values for each:")
200−
201− // Create input fields for each argument
202− inputFields := make([]string, len(m.inputs))
203− for i, input := range m.inputs {
204− // Highlight the label of the focused input
205− labelStyle := lipgloss.NewStyle().
206− Width(maxWidth).
207− Padding(1, 1, 0, 1).
208− Background(t.Background())
209−
210− if i == m.focusIndex {
211− labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
212− } else {
213− labelStyle = labelStyle.Foreground(t.TextMuted())
214− }
215−
216− label := labelStyle.Render(m.argNames[i] + ":")
217−
218− field := lipgloss.NewStyle().
219− Foreground(t.Text()).
220− Width(maxWidth).
221− Padding(0, 1).
222− Background(t.Background()).
223− Render(input.View())
224−
225− inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
226− }
227−
228− maxWidth = min(maxWidth, m.width-10)
229−
230− // Join all elements vertically
231− elements := []string{title, explanation}
232− elements = append(elements, inputFields...)
233−
234− content := lipgloss.JoinVertical(
235− lipgloss.Left,
236− elements...,
237− )
238−
239− return baseStyle.Padding(1, 2).
240− Border(lipgloss.RoundedBorder()).
241− BorderBackground(t.Background()).
242− BorderForeground(t.TextMuted()).
243− Background(t.Background()).
244− Width(lipgloss.Width(content) + 4).
245− Render(content)
246−}
247−
248−// SetSize sets the size of the component.
249−func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
250− m.width = width
251− m.height = height
252−}
253−
254−// Bindings implements layout.Bindings.
255−func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
256− return m.keys.ShortHelp()
257−}
internal/tui/components/dialog/commands.godeleted−180
@@ -1,180 +0,0 @@
1−package dialog
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7− utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
8− "github.com/opencode-ai/opencode/internal/tui/layout"
9− "github.com/opencode-ai/opencode/internal/tui/styles"
10− "github.com/opencode-ai/opencode/internal/tui/theme"
11− "github.com/opencode-ai/opencode/internal/tui/util"
12−)
13−
14−// Command represents a command that can be executed
15−type Command struct {
16− ID string
17− Title string
18− Description string
19− Handler func(cmd Command) tea.Cmd
20−}
21−
22−func (ci Command) Render(selected bool, width int) string {
23− t := theme.CurrentTheme()
24− baseStyle := styles.BaseStyle()
25−
26− descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
27− itemStyle := baseStyle.Width(width).
28− Foreground(t.Text()).
29− Background(t.Background())
30−
31− if selected {
32− itemStyle = itemStyle.
33− Background(t.Primary()).
34− Foreground(t.Background()).
35− Bold(true)
36− descStyle = descStyle.
37− Background(t.Primary()).
38− Foreground(t.Background())
39− }
40−
41− title := itemStyle.Padding(0, 1).Render(ci.Title)
42− if ci.Description != "" {
43− description := descStyle.Padding(0, 1).Render(ci.Description)
44− return lipgloss.JoinVertical(lipgloss.Left, title, description)
45− }
46− return title
47−}
48−
49−// CommandSelectedMsg is sent when a command is selected
50−type CommandSelectedMsg struct {
51− Command Command
52−}
53−
54−// CloseCommandDialogMsg is sent when the command dialog is closed
55−type CloseCommandDialogMsg struct{}
56−
57−// CommandDialog interface for the command selection dialog
58−type CommandDialog interface {
59− tea.Model
60− layout.Bindings
61− SetCommands(commands []Command)
62−}
63−
64−type commandDialogCmp struct {
65− listView utilComponents.SimpleList[Command]
66− width int
67− height int
68−}
69−
70−type commandKeyMap struct {
71− Enter key.Binding
72− Escape key.Binding
73−}
74−
75−var commandKeys = commandKeyMap{
76− Enter: key.NewBinding(
77− key.WithKeys("enter"),
78− key.WithHelp("enter", "select command"),
79− ),
80− Escape: key.NewBinding(
81− key.WithKeys("esc"),
82− key.WithHelp("esc", "close"),
83− ),
84−}
85−
86−func (c *commandDialogCmp) Init() tea.Cmd {
87− return c.listView.Init()
88−}
89−
90−func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91− var cmds []tea.Cmd
92− switch msg := msg.(type) {
93− case tea.KeyMsg:
94− switch {
95− case key.Matches(msg, commandKeys.Enter):
96− selectedItem, idx := c.listView.GetSelectedItem()
97− if idx != -1 {
98− return c, util.CmdHandler(CommandSelectedMsg{
99− Command: selectedItem,
100− })
101− }
102− case key.Matches(msg, commandKeys.Escape):
103− return c, util.CmdHandler(CloseCommandDialogMsg{})
104− }
105− case tea.WindowSizeMsg:
106− c.width = msg.Width
107− c.height = msg.Height
108− }
109−
110− u, cmd := c.listView.Update(msg)
111− c.listView = u.(utilComponents.SimpleList[Command])
112− cmds = append(cmds, cmd)
113−
114− return c, tea.Batch(cmds...)
115−}
116−
117−func (c *commandDialogCmp) View() string {
118− t := theme.CurrentTheme()
119− baseStyle := styles.BaseStyle()
120−
121− maxWidth := 40
122−
123− commands := c.listView.GetItems()
124−
125− for _, cmd := range commands {
126− if len(cmd.Title) > maxWidth-4 {
127− maxWidth = len(cmd.Title) + 4
128− }
129− if cmd.Description != "" {
130− if len(cmd.Description) > maxWidth-4 {
131− maxWidth = len(cmd.Description) + 4
132− }
133− }
134− }
135−
136− c.listView.SetMaxWidth(maxWidth)
137−
138− title := baseStyle.
139− Foreground(t.Primary()).
140− Bold(true).
141− Width(maxWidth).
142− Padding(0, 1).
143− Render("Commands")
144−
145− content := lipgloss.JoinVertical(
146− lipgloss.Left,
147− title,
148− baseStyle.Width(maxWidth).Render(""),
149− baseStyle.Width(maxWidth).Render(c.listView.View()),
150− baseStyle.Width(maxWidth).Render(""),
151− )
152−
153− return baseStyle.Padding(1, 2).
154− Border(lipgloss.RoundedBorder()).
155− BorderBackground(t.Background()).
156− BorderForeground(t.TextMuted()).
157− Width(lipgloss.Width(content) + 4).
158− Render(content)
159−}
160−
161−func (c *commandDialogCmp) BindingKeys() []key.Binding {
162− return layout.KeyMapToSlice(commandKeys)
163−}
164−
165−func (c *commandDialogCmp) SetCommands(commands []Command) {
166− c.listView.SetItems(commands)
167−}
168−
169−// NewCommandDialogCmp creates a new command selection dialog
170−func NewCommandDialogCmp() CommandDialog {
171− listView := utilComponents.NewSimpleList[Command](
172− []Command{},
173− 10,
174− "No commands available",
175− true,
176− )
177− return &commandDialogCmp{
178− listView: listView,
179− }
180−}
internal/tui/components/dialog/complete.godeleted−264
@@ -1,264 +0,0 @@
1−package dialog
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− "github.com/charmbracelet/bubbles/textarea"
6− tea "github.com/charmbracelet/bubbletea"
7− "github.com/charmbracelet/lipgloss"
8− "github.com/opencode-ai/opencode/internal/logging"
9− utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
10− "github.com/opencode-ai/opencode/internal/tui/layout"
11− "github.com/opencode-ai/opencode/internal/tui/styles"
12− "github.com/opencode-ai/opencode/internal/tui/theme"
13− "github.com/opencode-ai/opencode/internal/tui/util"
14−)
15−
16−type CompletionItem struct {
17− title string
18− Title string
19− Value string
20−}
21−
22−type CompletionItemI interface {
23− utilComponents.SimpleListItem
24− GetValue() string
25− DisplayValue() string
26−}
27−
28−func (ci *CompletionItem) Render(selected bool, width int) string {
29− t := theme.CurrentTheme()
30− baseStyle := styles.BaseStyle()
31−
32− itemStyle := baseStyle.
33− Width(width).
34− Padding(0, 1)
35−
36− if selected {
37− itemStyle = itemStyle.
38− Background(t.Background()).
39− Foreground(t.Primary()).
40− Bold(true)
41− }
42−
43− title := itemStyle.Render(
44− ci.GetValue(),
45− )
46−
47− return title
48−}
49−
50−func (ci *CompletionItem) DisplayValue() string {
51− return ci.Title
52−}
53−
54−func (ci *CompletionItem) GetValue() string {
55− return ci.Value
56−}
57−
58−func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
59− return &completionItem
60−}
61−
62−type CompletionProvider interface {
63− GetId() string
64− GetEntry() CompletionItemI
65− GetChildEntries(query string) ([]CompletionItemI, error)
66−}
67−
68−type CompletionSelectedMsg struct {
69− SearchString string
70− CompletionValue string
71−}
72−
73−type CompletionDialogCompleteItemMsg struct {
74− Value string
75−}
76−
77−type CompletionDialogCloseMsg struct{}
78−
79−type CompletionDialog interface {
80− tea.Model
81− layout.Bindings
82− SetWidth(width int)
83−}
84−
85−type completionDialogCmp struct {
86− query string
87− completionProvider CompletionProvider
88− width int
89− height int
90− pseudoSearchTextArea textarea.Model
91− listView utilComponents.SimpleList[CompletionItemI]
92−}
93−
94−type completionDialogKeyMap struct {
95− Complete key.Binding
96− Cancel key.Binding
97−}
98−
99−var completionDialogKeys = completionDialogKeyMap{
100− Complete: key.NewBinding(
101− key.WithKeys("tab", "enter"),
102− ),
103− Cancel: key.NewBinding(
104− key.WithKeys(" ", "esc", "backspace"),
105− ),
106−}
107−
108−func (c *completionDialogCmp) Init() tea.Cmd {
109− return nil
110−}
111−
112−func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
113− value := c.pseudoSearchTextArea.Value()
114−
115− if value == "" {
116− return nil
117− }
118−
119− return tea.Batch(
120− util.CmdHandler(CompletionSelectedMsg{
121− SearchString: value,
122− CompletionValue: item.GetValue(),
123− }),
124− c.close(),
125− )
126−}
127−
128−func (c *completionDialogCmp) close() tea.Cmd {
129− c.listView.SetItems([]CompletionItemI{})
130− c.pseudoSearchTextArea.Reset()
131− c.pseudoSearchTextArea.Blur()
132−
133− return util.CmdHandler(CompletionDialogCloseMsg{})
134−}
135−
136−func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
137− var cmds []tea.Cmd
138− switch msg := msg.(type) {
139− case tea.KeyMsg:
140− if c.pseudoSearchTextArea.Focused() {
141−
142− if !key.Matches(msg, completionDialogKeys.Complete) {
143−
144− var cmd tea.Cmd
145− c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
146− cmds = append(cmds, cmd)
147−
148− var query string
149− query = c.pseudoSearchTextArea.Value()
150− if query != "" {
151− query = query[1:]
152− }
153−
154− if query != c.query {
155− logging.Info("Query", query)
156− items, err := c.completionProvider.GetChildEntries(query)
157− if err != nil {
158− logging.Error("Failed to get child entries", err)
159− }
160−
161− c.listView.SetItems(items)
162− c.query = query
163− }
164−
165− u, cmd := c.listView.Update(msg)
166− c.listView = u.(utilComponents.SimpleList[CompletionItemI])
167−
168− cmds = append(cmds, cmd)
169− }
170−
171− switch {
172− case key.Matches(msg, completionDialogKeys.Complete):
173− item, i := c.listView.GetSelectedItem()
174− if i == -1 {
175− return c, nil
176− }
177−
178− cmd := c.complete(item)
179−
180− return c, cmd
181− case key.Matches(msg, completionDialogKeys.Cancel):
182− // Only close on backspace when there are no characters left
183− if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
184− return c, c.close()
185− }
186− }
187−
188− return c, tea.Batch(cmds...)
189− } else {
190− items, err := c.completionProvider.GetChildEntries("")
191− if err != nil {
192− logging.Error("Failed to get child entries", err)
193− }
194−
195− c.listView.SetItems(items)
196− c.pseudoSearchTextArea.SetValue(msg.String())
197− return c, c.pseudoSearchTextArea.Focus()
198− }
199− case tea.WindowSizeMsg:
200− c.width = msg.Width
201− c.height = msg.Height
202− }
203−
204− return c, tea.Batch(cmds...)
205−}
206−
207−func (c *completionDialogCmp) View() string {
208− t := theme.CurrentTheme()
209− baseStyle := styles.BaseStyle()
210−
211− maxWidth := 40
212−
213− completions := c.listView.GetItems()
214−
215− for _, cmd := range completions {
216− title := cmd.DisplayValue()
217− if len(title) > maxWidth-4 {
218− maxWidth = len(title) + 4
219− }
220− }
221−
222− c.listView.SetMaxWidth(maxWidth)
223−
224− return baseStyle.Padding(0, 0).
225− Border(lipgloss.NormalBorder()).
226− BorderBottom(false).
227− BorderRight(false).
228− BorderLeft(false).
229− BorderBackground(t.Background()).
230− BorderForeground(t.TextMuted()).
231− Width(c.width).
232− Render(c.listView.View())
233−}
234−
235−func (c *completionDialogCmp) SetWidth(width int) {
236− c.width = width
237−}
238−
239−func (c *completionDialogCmp) BindingKeys() []key.Binding {
240− return layout.KeyMapToSlice(completionDialogKeys)
241−}
242−
243−func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
244− ti := textarea.New()
245−
246− items, err := completionProvider.GetChildEntries("")
247− if err != nil {
248− logging.Error("Failed to get child entries", err)
249− }
250−
251− li := utilComponents.NewSimpleList(
252− items,
253− 7,
254− "No file matches found",
255− false,
256− )
257−
258− return &completionDialogCmp{
259− query: "",
260− completionProvider: completionProvider,
261− pseudoSearchTextArea: ti,
262− listView: li,
263− }
264−}
internal/tui/components/dialog/custom_commands.godeleted−186
@@ -1,186 +0,0 @@
1−package dialog
2−
3−import (
4− "fmt"
5− "os"
6− "path/filepath"
7− "regexp"
8− "strings"
9−
10− tea "github.com/charmbracelet/bubbletea"
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/tui/util"
13−)
14−
15−// Command prefix constants
16−const (
17− UserCommandPrefix = "user:"
18− ProjectCommandPrefix = "project:"
19−)
20−
21−// namedArgPattern is a regex pattern to find named arguments in the format $NAME
22−var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
23−
24−// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
25−func LoadCustomCommands() ([]Command, error) {
26− cfg := config.Get()
27− if cfg == nil {
28− return nil, fmt.Errorf("config not loaded")
29− }
30−
31− var commands []Command
32−
33− // Load user commands from XDG_CONFIG_HOME/opencode/commands
34− xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
35− if xdgConfigHome == "" {
36− // Default to ~/.config if XDG_CONFIG_HOME is not set
37− home, err := os.UserHomeDir()
38− if err == nil {
39− xdgConfigHome = filepath.Join(home, ".config")
40− }
41− }
42−
43− if xdgConfigHome != "" {
44− userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands")
45− userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix)
46− if err != nil {
47− // Log error but continue - we'll still try to load other commands
48− fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err)
49− } else {
50− commands = append(commands, userCommands...)
51− }
52− }
53−
54− // Load commands from $HOME/.opencode/commands
55− home, err := os.UserHomeDir()
56− if err == nil {
57− homeCommandsDir := filepath.Join(home, ".opencode", "commands")
58− homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
59− if err != nil {
60− // Log error but continue - we'll still try to load other commands
61− fmt.Printf("Warning: failed to load home commands: %v\n", err)
62− } else {
63− commands = append(commands, homeCommands...)
64− }
65− }
66−
67− // Load project commands from data directory
68− projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands")
69− projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
70− if err != nil {
71− // Log error but return what we have so far
72− fmt.Printf("Warning: failed to load project commands: %v\n", err)
73− } else {
74− commands = append(commands, projectCommands...)
75− }
76−
77− return commands, nil
78−}
79−
80−// loadCommandsFromDir loads commands from a specific directory with the given prefix
81−func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
82− // Check if the commands directory exists
83− if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
84− // Create the commands directory if it doesn't exist
85− if err := os.MkdirAll(commandsDir, 0755); err != nil {
86− return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
87− }
88− // Return empty list since we just created the directory
89− return []Command{}, nil
90− }
91−
92− var commands []Command
93−
94− // Walk through the commands directory and load all .md files
95− err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
96− if err != nil {
97− return err
98− }
99−
100− // Skip directories
101− if info.IsDir() {
102− return nil
103− }
104−
105− // Only process markdown files
106− if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
107− return nil
108− }
109−
110− // Read the file content
111− content, err := os.ReadFile(path)
112− if err != nil {
113− return fmt.Errorf("failed to read command file %s: %w", path, err)
114− }
115−
116− // Get the command ID from the file name without the .md extension
117− commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
118−
119− // Get relative path from commands directory
120− relPath, err := filepath.Rel(commandsDir, path)
121− if err != nil {
122− return fmt.Errorf("failed to get relative path for %s: %w", path, err)
123− }
124−
125− // Create the command ID from the relative path
126− // Replace directory separators with colons
127− commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
128− if commandIDPath != "." {
129− commandID = commandIDPath + ":" + commandID
130− }
131−
132− // Create a command
133− command := Command{
134− ID: prefix + commandID,
135− Title: prefix + commandID,
136− Description: fmt.Sprintf("Custom command from %s", relPath),
137− Handler: func(cmd Command) tea.Cmd {
138− commandContent := string(content)
139−
140− // Check for named arguments
141− matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
142− if len(matches) > 0 {
143− // Extract unique argument names
144− argNames := make([]string, 0)
145− argMap := make(map[string]bool)
146−
147− for _, match := range matches {
148− argName := match[1] // Group 1 is the name without $
149− if !argMap[argName] {
150− argMap[argName] = true
151− argNames = append(argNames, argName)
152− }
153− }
154−
155− // Show multi-arguments dialog for all named arguments
156− return util.CmdHandler(ShowMultiArgumentsDialogMsg{
157− CommandID: cmd.ID,
158− Content: commandContent,
159− ArgNames: argNames,
160− })
161− }
162−
163− // No arguments needed, run command directly
164− return util.CmdHandler(CommandRunCustomMsg{
165− Content: commandContent,
166− Args: nil, // No arguments
167− })
168− },
169− }
170−
171− commands = append(commands, command)
172− return nil
173− })
174−
175− if err != nil {
176− return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
177− }
178−
179− return commands, nil
180−}
181−
182−// CommandRunCustomMsg is sent when a custom command is executed
183−type CommandRunCustomMsg struct {
184− Content string
185− Args map[string]string // Map of argument names to values
186−}
internal/tui/components/dialog/custom_commands_test.godeleted−106
@@ -1,106 +0,0 @@
1−package dialog
2−
3−import (
4− "testing"
5− "regexp"
6−)
7−
8−func TestNamedArgPattern(t *testing.T) {
9− testCases := []struct {
10− input string
11− expected []string
12− }{
13− {
14− input: "This is a test with $ARGUMENTS placeholder",
15− expected: []string{"ARGUMENTS"},
16− },
17− {
18− input: "This is a test with $FOO and $BAR placeholders",
19− expected: []string{"FOO", "BAR"},
20− },
21− {
22− input: "This is a test with $FOO_BAR and $BAZ123 placeholders",
23− expected: []string{"FOO_BAR", "BAZ123"},
24− },
25− {
26− input: "This is a test with no placeholders",
27− expected: []string{},
28− },
29− {
30− input: "This is a test with $FOO appearing twice: $FOO",
31− expected: []string{"FOO"},
32− },
33− {
34− input: "This is a test with $1INVALID placeholder",
35− expected: []string{},
36− },
37− }
38−
39− for _, tc := range testCases {
40− matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
41−
42− // Extract unique argument names
43− argNames := make([]string, 0)
44− argMap := make(map[string]bool)
45−
46− for _, match := range matches {
47− argName := match[1] // Group 1 is the name without $
48− if !argMap[argName] {
49− argMap[argName] = true
50− argNames = append(argNames, argName)
51− }
52− }
53−
54− // Check if we got the expected number of arguments
55− if len(argNames) != len(tc.expected) {
56− t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
57− continue
58− }
59−
60− // Check if we got the expected argument names
61− for _, expectedArg := range tc.expected {
62− found := false
63− for _, actualArg := range argNames {
64− if actualArg == expectedArg {
65− found = true
66− break
67− }
68− }
69− if !found {
70− t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
71− }
72− }
73− }
74−}
75−
76−func TestRegexPattern(t *testing.T) {
77− pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
78−
79− validMatches := []string{
80− "$FOO",
81− "$BAR",
82− "$FOO_BAR",
83− "$BAZ123",
84− "$ARGUMENTS",
85− }
86−
87− invalidMatches := []string{
88− "$foo",
89− "$1BAR",
90− "$_FOO",
91− "FOO",
92− "$",
93− }
94−
95− for _, valid := range validMatches {
96− if !pattern.MatchString(valid) {
97− t.Errorf("Expected %s to match, but it didn't", valid)
98− }
99− }
100−
101− for _, invalid := range invalidMatches {
102− if pattern.MatchString(invalid) {
103− t.Errorf("Expected %s not to match, but it did", invalid)
104− }
105− }
106−}
internal/tui/components/dialog/filepicker.godeleted−471
@@ -1,471 +0,0 @@
1−package dialog
2−
3−import (
4− "fmt"
5− "net/http"
6− "os"
7− "path/filepath"
8− "sort"
9− "strings"
10− "time"
11−
12− "github.com/charmbracelet/bubbles/key"
13− "github.com/charmbracelet/bubbles/textinput"
14− "github.com/charmbracelet/bubbles/viewport"
15− tea "github.com/charmbracelet/bubbletea"
16− "github.com/charmbracelet/lipgloss"
17− "github.com/opencode-ai/opencode/internal/app"
18− "github.com/opencode-ai/opencode/internal/config"
19− "github.com/opencode-ai/opencode/internal/logging"
20− "github.com/opencode-ai/opencode/internal/message"
21− "github.com/opencode-ai/opencode/internal/tui/image"
22− "github.com/opencode-ai/opencode/internal/tui/styles"
23− "github.com/opencode-ai/opencode/internal/tui/theme"
24− "github.com/opencode-ai/opencode/internal/tui/util"
25−)
26−
27−const (
28− maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
29− downArrow = "down"
30− upArrow = "up"
31−)
32−
33−type FilePrickerKeyMap struct {
34− Enter key.Binding
35− Down key.Binding
36− Up key.Binding
37− Forward key.Binding
38− Backward key.Binding
39− OpenFilePicker key.Binding
40− Esc key.Binding
41− InsertCWD key.Binding
42−}
43−
44−var filePickerKeyMap = FilePrickerKeyMap{
45− Enter: key.NewBinding(
46− key.WithKeys("enter"),
47− key.WithHelp("enter", "select file/enter directory"),
48− ),
49− Down: key.NewBinding(
50− key.WithKeys("j", downArrow),
51− key.WithHelp("↓/j", "down"),
52− ),
53− Up: key.NewBinding(
54− key.WithKeys("k", upArrow),
55− key.WithHelp("↑/k", "up"),
56− ),
57− Forward: key.NewBinding(
58− key.WithKeys("l"),
59− key.WithHelp("l", "enter directory"),
60− ),
61− Backward: key.NewBinding(
62− key.WithKeys("h", "backspace"),
63− key.WithHelp("h/backspace", "go back"),
64− ),
65− OpenFilePicker: key.NewBinding(
66− key.WithKeys("ctrl+f"),
67− key.WithHelp("ctrl+f", "open file picker"),
68− ),
69− Esc: key.NewBinding(
70− key.WithKeys("esc"),
71− key.WithHelp("esc", "close/exit"),
72− ),
73− InsertCWD: key.NewBinding(
74− key.WithKeys("i"),
75− key.WithHelp("i", "manual path input"),
76− ),
77−}
78−
79−type filepickerCmp struct {
80− basePath string
81− width int
82− height int
83− cursor int
84− err error
85− cursorChain stack
86− viewport viewport.Model
87− dirs []os.DirEntry
88− cwdDetails *DirNode
89− selectedFile string
90− cwd textinput.Model
91− ShowFilePicker bool
92− app *app.App
93−}
94−
95−type DirNode struct {
96− parent *DirNode
97− child *DirNode
98− directory string
99−}
100−type stack []int
101−
102−func (s stack) Push(v int) stack {
103− return append(s, v)
104−}
105−
106−func (s stack) Pop() (stack, int) {
107− l := len(s)
108− return s[:l-1], s[l-1]
109−}
110−
111−type AttachmentAddedMsg struct {
112− Attachment message.Attachment
113−}
114−
115−func (f *filepickerCmp) Init() tea.Cmd {
116− return nil
117−}
118−
119−func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
120− var cmd tea.Cmd
121− switch msg := msg.(type) {
122− case tea.WindowSizeMsg:
123− f.width = 60
124− f.height = 20
125− f.viewport.Width = 80
126− f.viewport.Height = 22
127− f.cursor = 0
128− f.getCurrentFileBelowCursor()
129− case tea.KeyMsg:
130− if f.cwd.Focused() {
131− f.cwd, cmd = f.cwd.Update(msg)
132− }
133− switch {
134− case key.Matches(msg, filePickerKeyMap.InsertCWD):
135− f.cwd.Focus()
136− return f, cmd
137− case key.Matches(msg, filePickerKeyMap.Esc):
138− if f.cwd.Focused() {
139− f.cwd.Blur()
140− }
141− case key.Matches(msg, filePickerKeyMap.Down):
142− if !f.cwd.Focused() || msg.String() == downArrow {
143− if f.cursor < len(f.dirs)-1 {
144− f.cursor++
145− f.getCurrentFileBelowCursor()
146− }
147− }
148− case key.Matches(msg, filePickerKeyMap.Up):
149− if !f.cwd.Focused() || msg.String() == upArrow {
150− if f.cursor > 0 {
151− f.cursor--
152− f.getCurrentFileBelowCursor()
153− }
154− }
155− case key.Matches(msg, filePickerKeyMap.Enter):
156− var path string
157− var isPathDir bool
158− if f.cwd.Focused() {
159− path = f.cwd.Value()
160− fileInfo, err := os.Stat(path)
161− if err != nil {
162− logging.ErrorPersist("Invalid path")
163− return f, cmd
164− }
165− isPathDir = fileInfo.IsDir()
166− } else {
167− path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
168− isPathDir = f.dirs[f.cursor].IsDir()
169− }
170− if isPathDir {
171− newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
172− f.cwdDetails.child = &newWorkingDir
173− f.cwdDetails = f.cwdDetails.child
174− f.cursorChain = f.cursorChain.Push(f.cursor)
175− f.dirs = readDir(f.cwdDetails.directory, false)
176− f.cursor = 0
177− f.cwd.SetValue(f.cwdDetails.directory)
178− f.getCurrentFileBelowCursor()
179− } else {
180− f.selectedFile = path
181− return f.addAttachmentToMessage()
182− }
183− case key.Matches(msg, filePickerKeyMap.Esc):
184− if !f.cwd.Focused() {
185− f.cursorChain = make(stack, 0)
186− f.cursor = 0
187− } else {
188− f.cwd.Blur()
189− }
190− case key.Matches(msg, filePickerKeyMap.Forward):
191− if !f.cwd.Focused() {
192− if f.dirs[f.cursor].IsDir() {
193− path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
194− newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
195− f.cwdDetails.child = &newWorkingDir
196− f.cwdDetails = f.cwdDetails.child
197− f.cursorChain = f.cursorChain.Push(f.cursor)
198− f.dirs = readDir(f.cwdDetails.directory, false)
199− f.cursor = 0
200− f.cwd.SetValue(f.cwdDetails.directory)
201− f.getCurrentFileBelowCursor()
202− }
203− }
204− case key.Matches(msg, filePickerKeyMap.Backward):
205− if !f.cwd.Focused() {
206− if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
207− f.cursorChain, f.cursor = f.cursorChain.Pop()
208− f.cwdDetails = f.cwdDetails.parent
209− f.cwdDetails.child = nil
210− f.dirs = readDir(f.cwdDetails.directory, false)
211− f.cwd.SetValue(f.cwdDetails.directory)
212− f.getCurrentFileBelowCursor()
213− }
214− }
215− case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
216− f.dirs = readDir(f.cwdDetails.directory, false)
217− f.cursor = 0
218− f.getCurrentFileBelowCursor()
219− }
220− }
221− return f, cmd
222−}
223−
224−func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
225− modeInfo := GetSelectedModel(config.Get())
226− if !modeInfo.SupportsAttachments {
227− logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
228− return f, nil
229− }
230−
231− selectedFilePath := f.selectedFile
232− if !isExtSupported(selectedFilePath) {
233− logging.ErrorPersist("Unsupported file")
234− return f, nil
235− }
236−
237− isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
238− if err != nil {
239− logging.ErrorPersist("unable to read the image")
240− return f, nil
241− }
242− if isFileLarge {
243− logging.ErrorPersist("file too large, max 5MB")
244− return f, nil
245− }
246−
247− content, err := os.ReadFile(selectedFilePath)
248− if err != nil {
249− logging.ErrorPersist("Unable read selected file")
250− return f, nil
251− }
252−
253− mimeBufferSize := min(512, len(content))
254− mimeType := http.DetectContentType(content[:mimeBufferSize])
255− fileName := filepath.Base(selectedFilePath)
256− attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
257− f.selectedFile = ""
258− return f, util.CmdHandler(AttachmentAddedMsg{attachment})
259−}
260−
261−func (f *filepickerCmp) View() string {
262− t := theme.CurrentTheme()
263− const maxVisibleDirs = 20
264− const maxWidth = 80
265−
266− adjustedWidth := maxWidth
267− for _, file := range f.dirs {
268− if len(file.Name()) > adjustedWidth-4 { // Account for padding
269− adjustedWidth = len(file.Name()) + 4
270− }
271− }
272− adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
273−
274− files := make([]string, 0, maxVisibleDirs)
275− startIdx := 0
276−
277− if len(f.dirs) > maxVisibleDirs {
278− halfVisible := maxVisibleDirs / 2
279− if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
280− startIdx = f.cursor - halfVisible
281− } else if f.cursor >= len(f.dirs)-halfVisible {
282− startIdx = len(f.dirs) - maxVisibleDirs
283− }
284− }
285−
286− endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
287−
288− for i := startIdx; i < endIdx; i++ {
289− file := f.dirs[i]
290− itemStyle := styles.BaseStyle().Width(adjustedWidth)
291−
292− if i == f.cursor {
293− itemStyle = itemStyle.
294− Background(t.Primary()).
295− Foreground(t.Background()).
296− Bold(true)
297− }
298− filename := file.Name()
299−
300− if len(filename) > adjustedWidth-4 {
301− filename = filename[:adjustedWidth-7] + "..."
302− }
303− if file.IsDir() {
304− filename = filename + "/"
305− }
306− // No need to reassign filename if it's not changing
307−
308− files = append(files, itemStyle.Padding(0, 1).Render(filename))
309− }
310−
311− // Pad to always show exactly 21 lines
312− for len(files) < maxVisibleDirs {
313− files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
314− }
315−
316− currentPath := styles.BaseStyle().
317− Height(1).
318− Width(adjustedWidth).
319− Render(f.cwd.View())
320−
321− viewportstyle := lipgloss.NewStyle().
322− Width(f.viewport.Width).
323− Background(t.Background()).
324− Border(lipgloss.RoundedBorder()).
325− BorderForeground(t.TextMuted()).
326− BorderBackground(t.Background()).
327− Padding(2).
328− Render(f.viewport.View())
329− var insertExitText string
330− if f.IsCWDFocused() {
331− insertExitText = "Press esc to exit typing path"
332− } else {
333− insertExitText = "Press i to start typing path"
334− }
335−
336− content := lipgloss.JoinVertical(
337− lipgloss.Left,
338− currentPath,
339− styles.BaseStyle().Width(adjustedWidth).Render(""),
340− styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
341− styles.BaseStyle().Width(adjustedWidth).Render(""),
342− styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
343− )
344−
345− f.cwd.SetValue(f.cwd.Value())
346− contentStyle := styles.BaseStyle().Padding(1, 2).
347− Border(lipgloss.RoundedBorder()).
348− BorderBackground(t.Background()).
349− BorderForeground(t.TextMuted()).
350− Width(lipgloss.Width(content) + 4)
351−
352− return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
353−}
354−
355−type FilepickerCmp interface {
356− tea.Model
357− ToggleFilepicker(showFilepicker bool)
358− IsCWDFocused() bool
359−}
360−
361−func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
362− f.ShowFilePicker = showFilepicker
363−}
364−
365−func (f *filepickerCmp) IsCWDFocused() bool {
366− return f.cwd.Focused()
367−}
368−
369−func NewFilepickerCmp(app *app.App) FilepickerCmp {
370− homepath, err := os.UserHomeDir()
371− if err != nil {
372− logging.Error("error loading user files")
373− return nil
374− }
375− baseDir := DirNode{parent: nil, directory: homepath}
376− dirs := readDir(homepath, false)
377− viewport := viewport.New(0, 0)
378− currentDirectory := textinput.New()
379− currentDirectory.CharLimit = 200
380− currentDirectory.Width = 44
381− currentDirectory.Cursor.Blink = true
382− currentDirectory.SetValue(baseDir.directory)
383− return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
384−}
385−
386−func (f *filepickerCmp) getCurrentFileBelowCursor() {
387− if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
388− logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
389− f.viewport.SetContent("Preview unavailable")
390− return
391− }
392−
393− dir := f.dirs[f.cursor]
394− filename := dir.Name()
395− if !dir.IsDir() && isExtSupported(filename) {
396− fullPath := f.cwdDetails.directory + "/" + dir.Name()
397−
398− go func() {
399− imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
400− if err != nil {
401− logging.Error(err.Error())
402− f.viewport.SetContent("Preview unavailable")
403− return
404− }
405−
406− f.viewport.SetContent(imageString)
407− }()
408− } else {
409− f.viewport.SetContent("Preview unavailable")
410− }
411−}
412−
413−func readDir(path string, showHidden bool) []os.DirEntry {
414− logging.Info(fmt.Sprintf("Reading directory: %s", path))
415−
416− entriesChan := make(chan []os.DirEntry, 1)
417− errChan := make(chan error, 1)
418−
419− go func() {
420− dirEntries, err := os.ReadDir(path)
421− if err != nil {
422− logging.ErrorPersist(err.Error())
423− errChan <- err
424− return
425− }
426− entriesChan <- dirEntries
427− }()
428−
429− select {
430− case dirEntries := <-entriesChan:
431− sort.Slice(dirEntries, func(i, j int) bool {
432− if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
433− return dirEntries[i].Name() < dirEntries[j].Name()
434− }
435− return dirEntries[i].IsDir()
436− })
437−
438− if showHidden {
439− return dirEntries
440− }
441−
442− var sanitizedDirEntries []os.DirEntry
443− for _, dirEntry := range dirEntries {
444− isHidden, _ := IsHidden(dirEntry.Name())
445− if !isHidden {
446− if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
447− sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
448− }
449− }
450− }
451−
452− return sanitizedDirEntries
453−
454− case err := <-errChan:
455− logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
456− return []os.DirEntry{}
457−
458− case <-time.After(5 * time.Second):
459− logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
460− return []os.DirEntry{}
461− }
462−}
463−
464−func IsHidden(file string) (bool, error) {
465− return strings.HasPrefix(file, "."), nil
466−}
467−
468−func isExtSupported(path string) bool {
469− ext := strings.ToLower(filepath.Ext(path))
470− return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
471−}
internal/tui/components/dialog/help.godeleted−200
@@ -1,200 +0,0 @@
1−package dialog
2−
3−import (
4− "strings"
5−
6− "github.com/charmbracelet/bubbles/key"
7− tea "github.com/charmbracelet/bubbletea"
8− "github.com/charmbracelet/lipgloss"
9− "github.com/opencode-ai/opencode/internal/tui/styles"
10− "github.com/opencode-ai/opencode/internal/tui/theme"
11−)
12−
13−type helpCmp struct {
14− width int
15− height int
16− keys []key.Binding
17−}
18−
19−func (h *helpCmp) Init() tea.Cmd {
20− return nil
21−}
22−
23−func (h *helpCmp) SetBindings(k []key.Binding) {
24− h.keys = k
25−}
26−
27−func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
28− switch msg := msg.(type) {
29− case tea.WindowSizeMsg:
30− h.width = 90
31− h.height = msg.Height
32− }
33− return h, nil
34−}
35−
36−func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
37− seen := make(map[string]struct{})
38− result := make([]key.Binding, 0, len(bindings))
39−
40− // Process bindings in reverse order
41− for i := len(bindings) - 1; i >= 0; i-- {
42− b := bindings[i]
43− k := strings.Join(b.Keys(), " ")
44− if _, ok := seen[k]; ok {
45− // duplicate, skip
46− continue
47− }
48− seen[k] = struct{}{}
49− // Add to the beginning of result to maintain original order
50− result = append([]key.Binding{b}, result...)
51− }
52−
53− return result
54−}
55−
56−func (h *helpCmp) render() string {
57− t := theme.CurrentTheme()
58− baseStyle := styles.BaseStyle()
59−
60− helpKeyStyle := styles.Bold().
61− Background(t.Background()).
62− Foreground(t.Text()).
63− Padding(0, 1, 0, 0)
64−
65− helpDescStyle := styles.Regular().
66− Background(t.Background()).
67− Foreground(t.TextMuted())
68−
69− // Compile list of bindings to render
70− bindings := removeDuplicateBindings(h.keys)
71−
72− // Enumerate through each group of bindings, populating a series of
73− // pairs of columns, one for keys, one for descriptions
74− var (
75− pairs []string
76− width int
77− rows = 12 - 2
78− )
79−
80− for i := 0; i < len(bindings); i += rows {
81− var (
82− keys []string
83− descs []string
84− )
85− for j := i; j < min(i+rows, len(bindings)); j++ {
86− keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
87− descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
88− }
89−
90− // Render pair of columns; beyond the first pair, render a three space
91− // left margin, in order to visually separate the pairs.
92− var cols []string
93− if len(pairs) > 0 {
94− cols = []string{baseStyle.Render(" ")}
95− }
96−
97− maxDescWidth := 0
98− for _, desc := range descs {
99− if maxDescWidth < lipgloss.Width(desc) {
100− maxDescWidth = lipgloss.Width(desc)
101− }
102− }
103− for i := range descs {
104− remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
105− if remainingWidth > 0 {
106− descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
107− }
108− }
109− maxKeyWidth := 0
110− for _, key := range keys {
111− if maxKeyWidth < lipgloss.Width(key) {
112− maxKeyWidth = lipgloss.Width(key)
113− }
114− }
115− for i := range keys {
116− remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
117− if remainingWidth > 0 {
118− keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
119− }
120− }
121−
122− cols = append(cols,
123− strings.Join(keys, "\n"),
124− strings.Join(descs, "\n"),
125− )
126−
127− pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
128− // check whether it exceeds the maximum width avail (the width of the
129− // terminal, subtracting 2 for the borders).
130− width += lipgloss.Width(pair)
131− if width > h.width-2 {
132− break
133− }
134− pairs = append(pairs, pair)
135− }
136−
137− // https://github.com/charmbracelet/lipgloss/issues/209
138− if len(pairs) > 1 {
139− prefix := pairs[:len(pairs)-1]
140− lastPair := pairs[len(pairs)-1]
141− prefix = append(prefix, lipgloss.Place(
142− lipgloss.Width(lastPair), // width
143− lipgloss.Height(prefix[0]), // height
144− lipgloss.Left, // x
145− lipgloss.Top, // y
146− lastPair, // content
147− lipgloss.WithWhitespaceBackground(t.Background()),
148− ))
149− content := baseStyle.Width(h.width).Render(
150− lipgloss.JoinHorizontal(
151− lipgloss.Top,
152− prefix...,
153− ),
154− )
155− return content
156− }
157−
158− // Join pairs of columns and enclose in a border
159− content := baseStyle.Width(h.width).Render(
160− lipgloss.JoinHorizontal(
161− lipgloss.Top,
162− pairs...,
163− ),
164− )
165− return content
166−}
167−
168−func (h *helpCmp) View() string {
169− t := theme.CurrentTheme()
170− baseStyle := styles.BaseStyle()
171−
172− content := h.render()
173− header := baseStyle.
174− Bold(true).
175− Width(lipgloss.Width(content)).
176− Foreground(t.Primary()).
177− Render("Keyboard Shortcuts")
178−
179− return baseStyle.Padding(1).
180− Border(lipgloss.RoundedBorder()).
181− BorderForeground(t.TextMuted()).
182− Width(h.width).
183− BorderBackground(t.Background()).
184− Render(
185− lipgloss.JoinVertical(lipgloss.Center,
186− header,
187− baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
188− content,
189− ),
190− )
191−}
192−
193−type HelpCmp interface {
194− tea.Model
195− SetBindings([]key.Binding)
196−}
197−
198−func NewHelpCmp() HelpCmp {
199− return &helpCmp{}
200−}
internal/tui/components/dialog/init.godeleted−189
@@ -1,189 +0,0 @@
1−package dialog
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7−
8− "github.com/opencode-ai/opencode/internal/tui/styles"
9− "github.com/opencode-ai/opencode/internal/tui/theme"
10− "github.com/opencode-ai/opencode/internal/tui/util"
11−)
12−
13−// InitDialogCmp is a component that asks the user if they want to initialize the project.
14−type InitDialogCmp struct {
15− width, height int
16− selected int
17− keys initDialogKeyMap
18−}
19−
20−// NewInitDialogCmp creates a new InitDialogCmp.
21−func NewInitDialogCmp() InitDialogCmp {
22− return InitDialogCmp{
23− selected: 0,
24− keys: initDialogKeyMap{},
25− }
26−}
27−
28−type initDialogKeyMap struct {
29− Tab key.Binding
30− Left key.Binding
31− Right key.Binding
32− Enter key.Binding
33− Escape key.Binding
34− Y key.Binding
35− N key.Binding
36−}
37−
38−// ShortHelp implements key.Map.
39−func (k initDialogKeyMap) ShortHelp() []key.Binding {
40− return []key.Binding{
41− key.NewBinding(
42− key.WithKeys("tab", "left", "right"),
43− key.WithHelp("tab/←/→", "toggle selection"),
44− ),
45− key.NewBinding(
46− key.WithKeys("enter"),
47− key.WithHelp("enter", "confirm"),
48− ),
49− key.NewBinding(
50− key.WithKeys("esc", "q"),
51− key.WithHelp("esc/q", "cancel"),
52− ),
53− key.NewBinding(
54− key.WithKeys("y", "n"),
55− key.WithHelp("y/n", "yes/no"),
56− ),
57− }
58−}
59−
60−// FullHelp implements key.Map.
61−func (k initDialogKeyMap) FullHelp() [][]key.Binding {
62− return [][]key.Binding{k.ShortHelp()}
63−}
64−
65−// Init implements tea.Model.
66−func (m InitDialogCmp) Init() tea.Cmd {
67− return nil
68−}
69−
70−// Update implements tea.Model.
71−func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
72− switch msg := msg.(type) {
73− case tea.KeyMsg:
74− switch {
75− case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
76− return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
77− case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
78− m.selected = (m.selected + 1) % 2
79− return m, nil
80− case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
81− return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
82− case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
83− return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
84− case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
85− return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
86− }
87− case tea.WindowSizeMsg:
88− m.width = msg.Width
89− m.height = msg.Height
90− }
91− return m, nil
92−}
93−
94−// View implements tea.Model.
95−func (m InitDialogCmp) View() string {
96− t := theme.CurrentTheme()
97− baseStyle := styles.BaseStyle()
98−
99− // Calculate width needed for content
100− maxWidth := 60 // Width for explanation text
101−
102− title := baseStyle.
103− Foreground(t.Primary()).
104− Bold(true).
105− Width(maxWidth).
106− Padding(0, 1).
107− Render("Initialize Project")
108−
109− explanation := baseStyle.
110− Foreground(t.Text()).
111− Width(maxWidth).
112− Padding(0, 1).
113− 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.")
114−
115− question := baseStyle.
116− Foreground(t.Text()).
117− Width(maxWidth).
118− Padding(1, 1).
119− Render("Would you like to initialize this project?")
120−
121− maxWidth = min(maxWidth, m.width-10)
122− yesStyle := baseStyle
123− noStyle := baseStyle
124−
125− if m.selected == 0 {
126− yesStyle = yesStyle.
127− Background(t.Primary()).
128− Foreground(t.Background()).
129− Bold(true)
130− noStyle = noStyle.
131− Background(t.Background()).
132− Foreground(t.Primary())
133− } else {
134− noStyle = noStyle.
135− Background(t.Primary()).
136− Foreground(t.Background()).
137− Bold(true)
138− yesStyle = yesStyle.
139− Background(t.Background()).
140− Foreground(t.Primary())
141− }
142−
143− yes := yesStyle.Padding(0, 3).Render("Yes")
144− no := noStyle.Padding(0, 3).Render("No")
145−
146− buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no)
147− buttons = baseStyle.
148− Width(maxWidth).
149− Padding(1, 0).
150− Render(buttons)
151−
152− content := lipgloss.JoinVertical(
153− lipgloss.Left,
154− title,
155− baseStyle.Width(maxWidth).Render(""),
156− explanation,
157− question,
158− buttons,
159− baseStyle.Width(maxWidth).Render(""),
160− )
161−
162− return baseStyle.Padding(1, 2).
163− Border(lipgloss.RoundedBorder()).
164− BorderBackground(t.Background()).
165− BorderForeground(t.TextMuted()).
166− Width(lipgloss.Width(content) + 4).
167− Render(content)
168−}
169−
170−// SetSize sets the size of the component.
171−func (m *InitDialogCmp) SetSize(width, height int) {
172− m.width = width
173− m.height = height
174−}
175−
176−// Bindings implements layout.Bindings.
177−func (m InitDialogCmp) Bindings() []key.Binding {
178− return m.keys.ShortHelp()
179−}
180−
181−// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
182−type CloseInitDialogMsg struct {
183− Initialize bool
184−}
185−
186−// ShowInitDialogMsg is a message that is sent to show the init dialog.
187−type ShowInitDialogMsg struct {
188− Show bool
189−}
internal/tui/components/dialog/models.godeleted−373
@@ -1,373 +0,0 @@
1−package dialog
2−
3−import (
4− "fmt"
5− "slices"
6− "strings"
7−
8− "github.com/charmbracelet/bubbles/key"
9− tea "github.com/charmbracelet/bubbletea"
10− "github.com/charmbracelet/lipgloss"
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/llm/models"
13− "github.com/opencode-ai/opencode/internal/tui/layout"
14− "github.com/opencode-ai/opencode/internal/tui/styles"
15− "github.com/opencode-ai/opencode/internal/tui/theme"
16− "github.com/opencode-ai/opencode/internal/tui/util"
17−)
18−
19−const (
20− numVisibleModels = 10
21− maxDialogWidth = 40
22−)
23−
24−// ModelSelectedMsg is sent when a model is selected
25−type ModelSelectedMsg struct {
26− Model models.Model
27−}
28−
29−// CloseModelDialogMsg is sent when a model is selected
30−type CloseModelDialogMsg struct{}
31−
32−// ModelDialog interface for the model selection dialog
33−type ModelDialog interface {
34− tea.Model
35− layout.Bindings
36−}
37−
38−type modelDialogCmp struct {
39− models []models.Model
40− provider models.ModelProvider
41− availableProviders []models.ModelProvider
42−
43− selectedIdx int
44− width int
45− height int
46− scrollOffset int
47− hScrollOffset int
48− hScrollPossible bool
49−}
50−
51−type modelKeyMap struct {
52− Up key.Binding
53− Down key.Binding
54− Left key.Binding
55− Right key.Binding
56− Enter key.Binding
57− Escape key.Binding
58− J key.Binding
59− K key.Binding
60− H key.Binding
61− L key.Binding
62−}
63−
64−var modelKeys = modelKeyMap{
65− Up: key.NewBinding(
66− key.WithKeys("up"),
67− key.WithHelp("↑", "previous model"),
68− ),
69− Down: key.NewBinding(
70− key.WithKeys("down"),
71− key.WithHelp("↓", "next model"),
72− ),
73− Left: key.NewBinding(
74− key.WithKeys("left"),
75− key.WithHelp("←", "scroll left"),
76− ),
77− Right: key.NewBinding(
78− key.WithKeys("right"),
79− key.WithHelp("→", "scroll right"),
80− ),
81− Enter: key.NewBinding(
82− key.WithKeys("enter"),
83− key.WithHelp("enter", "select model"),
84− ),
85− Escape: key.NewBinding(
86− key.WithKeys("esc"),
87− key.WithHelp("esc", "close"),
88− ),
89− J: key.NewBinding(
90− key.WithKeys("j"),
91− key.WithHelp("j", "next model"),
92− ),
93− K: key.NewBinding(
94− key.WithKeys("k"),
95− key.WithHelp("k", "previous model"),
96− ),
97− H: key.NewBinding(
98− key.WithKeys("h"),
99− key.WithHelp("h", "scroll left"),
100− ),
101− L: key.NewBinding(
102− key.WithKeys("l"),
103− key.WithHelp("l", "scroll right"),
104− ),
105−}
106−
107−func (m *modelDialogCmp) Init() tea.Cmd {
108− m.setupModels()
109− return nil
110−}
111−
112−func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113− switch msg := msg.(type) {
114− case tea.KeyMsg:
115− switch {
116− case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
117− m.moveSelectionUp()
118− case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
119− m.moveSelectionDown()
120− case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
121− if m.hScrollPossible {
122− m.switchProvider(-1)
123− }
124− case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
125− if m.hScrollPossible {
126− m.switchProvider(1)
127− }
128− case key.Matches(msg, modelKeys.Enter):
129− util.ReportInfo(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name))
130− return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
131− case key.Matches(msg, modelKeys.Escape):
132− return m, util.CmdHandler(CloseModelDialogMsg{})
133− }
134− case tea.WindowSizeMsg:
135− m.width = msg.Width
136− m.height = msg.Height
137− }
138−
139− return m, nil
140−}
141−
142−// moveSelectionUp moves the selection up or wraps to bottom
143−func (m *modelDialogCmp) moveSelectionUp() {
144− if m.selectedIdx > 0 {
145− m.selectedIdx--
146− } else {
147− m.selectedIdx = len(m.models) - 1
148− m.scrollOffset = max(0, len(m.models)-numVisibleModels)
149− }
150−
151− // Keep selection visible
152− if m.selectedIdx < m.scrollOffset {
153− m.scrollOffset = m.selectedIdx
154− }
155−}
156−
157−// moveSelectionDown moves the selection down or wraps to top
158−func (m *modelDialogCmp) moveSelectionDown() {
159− if m.selectedIdx < len(m.models)-1 {
160− m.selectedIdx++
161− } else {
162− m.selectedIdx = 0
163− m.scrollOffset = 0
164− }
165−
166− // Keep selection visible
167− if m.selectedIdx >= m.scrollOffset+numVisibleModels {
168− m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
169− }
170−}
171−
172−func (m *modelDialogCmp) switchProvider(offset int) {
173− newOffset := m.hScrollOffset + offset
174−
175− // Ensure we stay within bounds
176− if newOffset < 0 {
177− newOffset = len(m.availableProviders) - 1
178− }
179− if newOffset >= len(m.availableProviders) {
180− newOffset = 0
181− }
182−
183− m.hScrollOffset = newOffset
184− m.provider = m.availableProviders[m.hScrollOffset]
185− m.setupModelsForProvider(m.provider)
186−}
187−
188−func (m *modelDialogCmp) View() string {
189− t := theme.CurrentTheme()
190− baseStyle := styles.BaseStyle()
191−
192− // Capitalize first letter of provider name
193− providerName := strings.ToUpper(string(m.provider)[:1]) + string(m.provider[1:])
194− title := baseStyle.
195− Foreground(t.Primary()).
196− Bold(true).
197− Width(maxDialogWidth).
198− Padding(0, 0, 1).
199− Render(fmt.Sprintf("Select %s Model", providerName))
200−
201− // Render visible models
202− endIdx := min(m.scrollOffset+numVisibleModels, len(m.models))
203− modelItems := make([]string, 0, endIdx-m.scrollOffset)
204−
205− for i := m.scrollOffset; i < endIdx; i++ {
206− itemStyle := baseStyle.Width(maxDialogWidth)
207− if i == m.selectedIdx {
208− itemStyle = itemStyle.Background(t.Primary()).
209− Foreground(t.Background()).Bold(true)
210− }
211− modelItems = append(modelItems, itemStyle.Render(m.models[i].Name))
212− }
213−
214− scrollIndicator := m.getScrollIndicators(maxDialogWidth)
215−
216− content := lipgloss.JoinVertical(
217− lipgloss.Left,
218− title,
219− baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
220− scrollIndicator,
221− )
222−
223− return baseStyle.Padding(1, 2).
224− Border(lipgloss.RoundedBorder()).
225− BorderBackground(t.Background()).
226− BorderForeground(t.TextMuted()).
227− Width(lipgloss.Width(content) + 4).
228− Render(content)
229−}
230−
231−func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
232− var indicator string
233−
234− if len(m.models) > numVisibleModels {
235− if m.scrollOffset > 0 {
236− indicator += "↑ "
237− }
238− if m.scrollOffset+numVisibleModels < len(m.models) {
239− indicator += "↓ "
240− }
241− }
242−
243− if m.hScrollPossible {
244− if m.hScrollOffset > 0 {
245− indicator = "← " + indicator
246− }
247− if m.hScrollOffset < len(m.availableProviders)-1 {
248− indicator += "→"
249− }
250− }
251−
252− if indicator == "" {
253− return ""
254− }
255−
256− t := theme.CurrentTheme()
257− baseStyle := styles.BaseStyle()
258−
259− return baseStyle.
260− Foreground(t.Primary()).
261− Width(maxWidth).
262− Align(lipgloss.Right).
263− Bold(true).
264− Render(indicator)
265−}
266−
267−func (m *modelDialogCmp) BindingKeys() []key.Binding {
268− return layout.KeyMapToSlice(modelKeys)
269−}
270−
271−func (m *modelDialogCmp) setupModels() {
272− cfg := config.Get()
273− modelInfo := GetSelectedModel(cfg)
274− m.availableProviders = getEnabledProviders(cfg)
275− m.hScrollPossible = len(m.availableProviders) > 1
276−
277− m.provider = modelInfo.Provider
278− m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
279−
280− m.setupModelsForProvider(m.provider)
281−}
282−
283−func GetSelectedModel(cfg *config.Config) models.Model {
284−
285− agentCfg := cfg.Agents[config.AgentCoder]
286− selectedModelId := agentCfg.Model
287− return models.SupportedModels[selectedModelId]
288−}
289−
290−func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
291− var providers []models.ModelProvider
292− for providerId, provider := range cfg.Providers {
293− if !provider.Disabled {
294− providers = append(providers, providerId)
295− }
296− }
297−
298− // Sort by provider popularity
299− slices.SortFunc(providers, func(a, b models.ModelProvider) int {
300− rA := models.ProviderPopularity[a]
301− rB := models.ProviderPopularity[b]
302−
303− // models not included in popularity ranking default to last
304− if rA == 0 {
305− rA = 999
306− }
307− if rB == 0 {
308− rB = 999
309− }
310− return rA - rB
311− })
312− return providers
313−}
314−
315−// findProviderIndex returns the index of the provider in the list, or -1 if not found
316−func findProviderIndex(providers []models.ModelProvider, provider models.ModelProvider) int {
317− for i, p := range providers {
318− if p == provider {
319− return i
320− }
321− }
322− return -1
323−}
324−
325−func (m *modelDialogCmp) setupModelsForProvider(provider models.ModelProvider) {
326− cfg := config.Get()
327− agentCfg := cfg.Agents[config.AgentCoder]
328− selectedModelId := agentCfg.Model
329−
330− m.provider = provider
331− m.models = getModelsForProvider(provider)
332− m.selectedIdx = 0
333− m.scrollOffset = 0
334−
335− // Try to select the current model if it belongs to this provider
336− if provider == models.SupportedModels[selectedModelId].Provider {
337− for i, model := range m.models {
338− if model.ID == selectedModelId {
339− m.selectedIdx = i
340− // Adjust scroll position to keep selected model visible
341− if m.selectedIdx >= numVisibleModels {
342− m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
343− }
344− break
345− }
346− }
347− }
348−}
349−
350−func getModelsForProvider(provider models.ModelProvider) []models.Model {
351− var providerModels []models.Model
352− for _, model := range models.SupportedModels {
353− if model.Provider == provider {
354− providerModels = append(providerModels, model)
355− }
356− }
357−
358− // reverse alphabetical order (if llm naming was consistent latest would appear first)
359− slices.SortFunc(providerModels, func(a, b models.Model) int {
360− if a.Name > b.Name {
361− return -1
362− } else if a.Name < b.Name {
363− return 1
364− }
365− return 0
366− })
367−
368− return providerModels
369−}
370−
371−func NewModelDialogCmp() ModelDialog {
372− return &modelDialogCmp{}
373−}
internal/tui/components/dialog/permission.godeleted−522
@@ -1,522 +0,0 @@
1−package dialog
2−
3−import (
4− "fmt"
5− "strings"
6−
7− "github.com/charmbracelet/bubbles/key"
8− "github.com/charmbracelet/bubbles/viewport"
9− tea "github.com/charmbracelet/bubbletea"
10− "github.com/charmbracelet/lipgloss"
11− "github.com/opencode-ai/opencode/internal/diff"
12− "github.com/opencode-ai/opencode/internal/llm/tools"
13− "github.com/opencode-ai/opencode/internal/permission"
14− "github.com/opencode-ai/opencode/internal/tui/layout"
15− "github.com/opencode-ai/opencode/internal/tui/styles"
16− "github.com/opencode-ai/opencode/internal/tui/theme"
17− "github.com/opencode-ai/opencode/internal/tui/util"
18−)
19−
20−type PermissionAction string
21−
22−// Permission responses
23−const (
24− PermissionAllow PermissionAction = "allow"
25− PermissionAllowForSession PermissionAction = "allow_session"
26− PermissionDeny PermissionAction = "deny"
27−)
28−
29−// PermissionResponseMsg represents the user's response to a permission request
30−type PermissionResponseMsg struct {
31− Permission permission.PermissionRequest
32− Action PermissionAction
33−}
34−
35−// PermissionDialogCmp interface for permission dialog component
36−type PermissionDialogCmp interface {
37− tea.Model
38− layout.Bindings
39− SetPermissions(permission permission.PermissionRequest) tea.Cmd
40−}
41−
42−type permissionsMapping struct {
43− Left key.Binding
44− Right key.Binding
45− EnterSpace key.Binding
46− Allow key.Binding
47− AllowSession key.Binding
48− Deny key.Binding
49− Tab key.Binding
50−}
51−
52−var permissionsKeys = permissionsMapping{
53− Left: key.NewBinding(
54− key.WithKeys("left"),
55− key.WithHelp("←", "switch options"),
56− ),
57− Right: key.NewBinding(
58− key.WithKeys("right"),
59− key.WithHelp("→", "switch options"),
60− ),
61− EnterSpace: key.NewBinding(
62− key.WithKeys("enter", " "),
63− key.WithHelp("enter/space", "confirm"),
64− ),
65− Allow: key.NewBinding(
66− key.WithKeys("a"),
67− key.WithHelp("a", "allow"),
68− ),
69− AllowSession: key.NewBinding(
70− key.WithKeys("s"),
71− key.WithHelp("s", "allow for session"),
72− ),
73− Deny: key.NewBinding(
74− key.WithKeys("d"),
75− key.WithHelp("d", "deny"),
76− ),
77− Tab: key.NewBinding(
78− key.WithKeys("tab"),
79− key.WithHelp("tab", "switch options"),
80− ),
81−}
82−
83−// permissionDialogCmp is the implementation of PermissionDialog
84−type permissionDialogCmp struct {
85− width int
86− height int
87− permission permission.PermissionRequest
88− windowSize tea.WindowSizeMsg
89− contentViewPort viewport.Model
90− selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
91−
92− diffCache map[string]string
93− markdownCache map[string]string
94−}
95−
96−func (p *permissionDialogCmp) Init() tea.Cmd {
97− return p.contentViewPort.Init()
98−}
99−
100−func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101− var cmds []tea.Cmd
102−
103− switch msg := msg.(type) {
104− case tea.WindowSizeMsg:
105− p.windowSize = msg
106− cmd := p.SetSize()
107− cmds = append(cmds, cmd)
108− p.markdownCache = make(map[string]string)
109− p.diffCache = make(map[string]string)
110− case tea.KeyMsg:
111− switch {
112− case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
113− p.selectedOption = (p.selectedOption + 1) % 3
114− return p, nil
115− case key.Matches(msg, permissionsKeys.Left):
116− p.selectedOption = (p.selectedOption + 2) % 3
117− case key.Matches(msg, permissionsKeys.EnterSpace):
118− return p, p.selectCurrentOption()
119− case key.Matches(msg, permissionsKeys.Allow):
120− return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
121− case key.Matches(msg, permissionsKeys.AllowSession):
122− return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
123− case key.Matches(msg, permissionsKeys.Deny):
124− return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
125− default:
126− // Pass other keys to viewport
127− viewPort, cmd := p.contentViewPort.Update(msg)
128− p.contentViewPort = viewPort
129− cmds = append(cmds, cmd)
130− }
131− }
132−
133− return p, tea.Batch(cmds...)
134−}
135−
136−func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
137− var action PermissionAction
138−
139− switch p.selectedOption {
140− case 0:
141− action = PermissionAllow
142− case 1:
143− action = PermissionAllowForSession
144− case 2:
145− action = PermissionDeny
146− }
147−
148− return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
149−}
150−
151−func (p *permissionDialogCmp) renderButtons() string {
152− t := theme.CurrentTheme()
153− baseStyle := styles.BaseStyle()
154−
155− allowStyle := baseStyle
156− allowSessionStyle := baseStyle
157− denyStyle := baseStyle
158− spacerStyle := baseStyle.Background(t.Background())
159−
160− // Style the selected button
161− switch p.selectedOption {
162− case 0:
163− allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
164− allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
165− denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
166− case 1:
167− allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
168− allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
169− denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
170− case 2:
171− allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
172− allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
173− denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
174− }
175−
176− allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
177− allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
178− denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
179−
180− content := lipgloss.JoinHorizontal(
181− lipgloss.Left,
182− allowButton,
183− spacerStyle.Render(" "),
184− allowSessionButton,
185− spacerStyle.Render(" "),
186− denyButton,
187− spacerStyle.Render(" "),
188− )
189−
190− remainingWidth := p.width - lipgloss.Width(content)
191− if remainingWidth > 0 {
192− content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
193− }
194− return content
195−}
196−
197−func (p *permissionDialogCmp) renderHeader() string {
198− t := theme.CurrentTheme()
199− baseStyle := styles.BaseStyle()
200−
201− toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
202− toolValue := baseStyle.
203− Foreground(t.Text()).
204− Width(p.width - lipgloss.Width(toolKey)).
205− Render(fmt.Sprintf(": %s", p.permission.ToolName))
206−
207− pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
208− pathValue := baseStyle.
209− Foreground(t.Text()).
210− Width(p.width - lipgloss.Width(pathKey)).
211− Render(fmt.Sprintf(": %s", p.permission.Path))
212−
213− headerParts := []string{
214− lipgloss.JoinHorizontal(
215− lipgloss.Left,
216− toolKey,
217− toolValue,
218− ),
219− baseStyle.Render(strings.Repeat(" ", p.width)),
220− lipgloss.JoinHorizontal(
221− lipgloss.Left,
222− pathKey,
223− pathValue,
224− ),
225− baseStyle.Render(strings.Repeat(" ", p.width)),
226− }
227−
228− // Add tool-specific header information
229− switch p.permission.ToolName {
230− case tools.BashToolName:
231− headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
232− case tools.EditToolName:
233− params := p.permission.Params.(tools.EditPermissionsParams)
234− fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
235− filePath := baseStyle.
236− Foreground(t.Text()).
237− Width(p.width - lipgloss.Width(fileKey)).
238− Render(fmt.Sprintf(": %s", params.FilePath))
239− headerParts = append(headerParts,
240− lipgloss.JoinHorizontal(
241− lipgloss.Left,
242− fileKey,
243− filePath,
244− ),
245− baseStyle.Render(strings.Repeat(" ", p.width)),
246− )
247−
248− case tools.WriteToolName:
249− params := p.permission.Params.(tools.WritePermissionsParams)
250− fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
251− filePath := baseStyle.
252− Foreground(t.Text()).
253− Width(p.width - lipgloss.Width(fileKey)).
254− Render(fmt.Sprintf(": %s", params.FilePath))
255− headerParts = append(headerParts,
256− lipgloss.JoinHorizontal(
257− lipgloss.Left,
258− fileKey,
259− filePath,
260− ),
261− baseStyle.Render(strings.Repeat(" ", p.width)),
262− )
263− case tools.FetchToolName:
264− headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
265− }
266−
267− return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
268−}
269−
270−func (p *permissionDialogCmp) renderBashContent() string {
271− t := theme.CurrentTheme()
272− baseStyle := styles.BaseStyle()
273−
274− if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
275− content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
276−
277− // Use the cache for markdown rendering
278− renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
279− r := styles.GetMarkdownRenderer(p.width - 10)
280− s, err := r.Render(content)
281− return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
282− })
283−
284− finalContent := baseStyle.
285− Width(p.contentViewPort.Width).
286− Render(renderedContent)
287− p.contentViewPort.SetContent(finalContent)
288− return p.styleViewport()
289− }
290− return ""
291−}
292−
293−func (p *permissionDialogCmp) renderEditContent() string {
294− if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
295− diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
296− return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
297− })
298−
299− p.contentViewPort.SetContent(diff)
300− return p.styleViewport()
301− }
302− return ""
303−}
304−
305−func (p *permissionDialogCmp) renderPatchContent() string {
306− if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
307− diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
308− return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
309− })
310−
311− p.contentViewPort.SetContent(diff)
312− return p.styleViewport()
313− }
314− return ""
315−}
316−
317−func (p *permissionDialogCmp) renderWriteContent() string {
318− if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
319− // Use the cache for diff rendering
320− diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
321− return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
322− })
323−
324− p.contentViewPort.SetContent(diff)
325− return p.styleViewport()
326− }
327− return ""
328−}
329−
330−func (p *permissionDialogCmp) renderFetchContent() string {
331− t := theme.CurrentTheme()
332− baseStyle := styles.BaseStyle()
333−
334− if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
335− content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
336−
337− // Use the cache for markdown rendering
338− renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
339− r := styles.GetMarkdownRenderer(p.width - 10)
340− s, err := r.Render(content)
341− return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
342− })
343−
344− finalContent := baseStyle.
345− Width(p.contentViewPort.Width).
346− Render(renderedContent)
347− p.contentViewPort.SetContent(finalContent)
348− return p.styleViewport()
349− }
350− return ""
351−}
352−
353−func (p *permissionDialogCmp) renderDefaultContent() string {
354− t := theme.CurrentTheme()
355− baseStyle := styles.BaseStyle()
356−
357− content := p.permission.Description
358−
359− // Use the cache for markdown rendering
360− renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
361− r := styles.GetMarkdownRenderer(p.width - 10)
362− s, err := r.Render(content)
363− return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
364− })
365−
366− finalContent := baseStyle.
367− Width(p.contentViewPort.Width).
368− Render(renderedContent)
369− p.contentViewPort.SetContent(finalContent)
370−
371− if renderedContent == "" {
372− return ""
373− }
374−
375− return p.styleViewport()
376−}
377−
378−func (p *permissionDialogCmp) styleViewport() string {
379− t := theme.CurrentTheme()
380− contentStyle := lipgloss.NewStyle().
381− Background(t.Background())
382−
383− return contentStyle.Render(p.contentViewPort.View())
384−}
385−
386−func (p *permissionDialogCmp) render() string {
387− t := theme.CurrentTheme()
388− baseStyle := styles.BaseStyle()
389−
390− title := baseStyle.
391− Bold(true).
392− Width(p.width - 4).
393− Foreground(t.Primary()).
394− Render("Permission Required")
395− // Render header
396− headerContent := p.renderHeader()
397− // Render buttons
398− buttons := p.renderButtons()
399−
400− // Calculate content height dynamically based on window size
401− p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
402− p.contentViewPort.Width = p.width - 4
403−
404− // Render content based on tool type
405− var contentFinal string
406− switch p.permission.ToolName {
407− case tools.BashToolName:
408− contentFinal = p.renderBashContent()
409− case tools.EditToolName:
410− contentFinal = p.renderEditContent()
411− case tools.PatchToolName:
412− contentFinal = p.renderPatchContent()
413− case tools.WriteToolName:
414− contentFinal = p.renderWriteContent()
415− case tools.FetchToolName:
416− contentFinal = p.renderFetchContent()
417− default:
418− contentFinal = p.renderDefaultContent()
419− }
420−
421− content := lipgloss.JoinVertical(
422− lipgloss.Top,
423− title,
424− baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
425− headerContent,
426− contentFinal,
427− buttons,
428− baseStyle.Render(strings.Repeat(" ", p.width-4)),
429− )
430−
431− return baseStyle.
432− Padding(1, 0, 0, 1).
433− Border(lipgloss.RoundedBorder()).
434− BorderBackground(t.Background()).
435− BorderForeground(t.TextMuted()).
436− Width(p.width).
437− Height(p.height).
438− Render(
439− content,
440− )
441−}
442−
443−func (p *permissionDialogCmp) View() string {
444− return p.render()
445−}
446−
447−func (p *permissionDialogCmp) BindingKeys() []key.Binding {
448− return layout.KeyMapToSlice(permissionsKeys)
449−}
450−
451−func (p *permissionDialogCmp) SetSize() tea.Cmd {
452− if p.permission.ID == "" {
453− return nil
454− }
455− switch p.permission.ToolName {
456− case tools.BashToolName:
457− p.width = int(float64(p.windowSize.Width) * 0.4)
458− p.height = int(float64(p.windowSize.Height) * 0.3)
459− case tools.EditToolName:
460− p.width = int(float64(p.windowSize.Width) * 0.8)
461− p.height = int(float64(p.windowSize.Height) * 0.8)
462− case tools.WriteToolName:
463− p.width = int(float64(p.windowSize.Width) * 0.8)
464− p.height = int(float64(p.windowSize.Height) * 0.8)
465− case tools.FetchToolName:
466− p.width = int(float64(p.windowSize.Width) * 0.4)
467− p.height = int(float64(p.windowSize.Height) * 0.3)
468− default:
469− p.width = int(float64(p.windowSize.Width) * 0.7)
470− p.height = int(float64(p.windowSize.Height) * 0.5)
471− }
472− return nil
473−}
474−
475−func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
476− p.permission = permission
477− return p.SetSize()
478−}
479−
480−// Helper to get or set cached diff content
481−func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
482− if cached, ok := c.diffCache[key]; ok {
483− return cached
484− }
485−
486− content, err := generator()
487− if err != nil {
488− return fmt.Sprintf("Error formatting diff: %v", err)
489− }
490−
491− c.diffCache[key] = content
492−
493− return content
494−}
495−
496−// Helper to get or set cached markdown content
497−func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
498− if cached, ok := c.markdownCache[key]; ok {
499− return cached
500− }
501−
502− content, err := generator()
503− if err != nil {
504− return fmt.Sprintf("Error rendering markdown: %v", err)
505− }
506−
507− c.markdownCache[key] = content
508−
509− return content
510−}
511−
512−func NewPermissionDialogCmp() PermissionDialogCmp {
513− // Create viewport for content
514− contentViewport := viewport.New(0, 0)
515−
516− return &permissionDialogCmp{
517− contentViewPort: contentViewport,
518− selectedOption: 0, // Default to "Allow"
519− diffCache: make(map[string]string),
520− markdownCache: make(map[string]string),
521− }
522−}
internal/tui/components/dialog/quit.godeleted−136
@@ -1,136 +0,0 @@
1−package dialog
2−
3−import (
4− "strings"
5−
6− "github.com/charmbracelet/bubbles/key"
7− tea "github.com/charmbracelet/bubbletea"
8− "github.com/charmbracelet/lipgloss"
9− "github.com/opencode-ai/opencode/internal/tui/layout"
10− "github.com/opencode-ai/opencode/internal/tui/styles"
11− "github.com/opencode-ai/opencode/internal/tui/theme"
12− "github.com/opencode-ai/opencode/internal/tui/util"
13−)
14−
15−const question = "Are you sure you want to quit?"
16−
17−type CloseQuitMsg struct{}
18−
19−type QuitDialog interface {
20− tea.Model
21− layout.Bindings
22−}
23−
24−type quitDialogCmp struct {
25− selectedNo bool
26−}
27−
28−type helpMapping struct {
29− LeftRight key.Binding
30− EnterSpace key.Binding
31− Yes key.Binding
32− No key.Binding
33− Tab key.Binding
34−}
35−
36−var helpKeys = helpMapping{
37− LeftRight: key.NewBinding(
38− key.WithKeys("left", "right"),
39− key.WithHelp("←/→", "switch options"),
40− ),
41− EnterSpace: key.NewBinding(
42− key.WithKeys("enter", " "),
43− key.WithHelp("enter/space", "confirm"),
44− ),
45− Yes: key.NewBinding(
46− key.WithKeys("y", "Y"),
47− key.WithHelp("y/Y", "yes"),
48− ),
49− No: key.NewBinding(
50− key.WithKeys("n", "N"),
51− key.WithHelp("n/N", "no"),
52− ),
53− Tab: key.NewBinding(
54− key.WithKeys("tab"),
55− key.WithHelp("tab", "switch options"),
56− ),
57−}
58−
59−func (q *quitDialogCmp) Init() tea.Cmd {
60− return nil
61−}
62−
63−func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
64− switch msg := msg.(type) {
65− case tea.KeyMsg:
66− switch {
67− case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
68− q.selectedNo = !q.selectedNo
69− return q, nil
70− case key.Matches(msg, helpKeys.EnterSpace):
71− if !q.selectedNo {
72− return q, tea.Quit
73− }
74− return q, util.CmdHandler(CloseQuitMsg{})
75− case key.Matches(msg, helpKeys.Yes):
76− return q, tea.Quit
77− case key.Matches(msg, helpKeys.No):
78− return q, util.CmdHandler(CloseQuitMsg{})
79− }
80− }
81− return q, nil
82−}
83−
84−func (q *quitDialogCmp) View() string {
85− t := theme.CurrentTheme()
86− baseStyle := styles.BaseStyle()
87−
88− yesStyle := baseStyle
89− noStyle := baseStyle
90− spacerStyle := baseStyle.Background(t.Background())
91−
92− if q.selectedNo {
93− noStyle = noStyle.Background(t.Primary()).Foreground(t.Background())
94− yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary())
95− } else {
96− yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background())
97− noStyle = noStyle.Background(t.Background()).Foreground(t.Primary())
98− }
99−
100− yesButton := yesStyle.Padding(0, 1).Render("Yes")
101− noButton := noStyle.Padding(0, 1).Render("No")
102−
103− buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
104−
105− width := lipgloss.Width(question)
106− remainingWidth := width - lipgloss.Width(buttons)
107− if remainingWidth > 0 {
108− buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
109− }
110−
111− content := baseStyle.Render(
112− lipgloss.JoinVertical(
113− lipgloss.Center,
114− question,
115− "",
116− buttons,
117− ),
118− )
119−
120− return baseStyle.Padding(1, 2).
121− Border(lipgloss.RoundedBorder()).
122− BorderBackground(t.Background()).
123− BorderForeground(t.TextMuted()).
124− Width(lipgloss.Width(content) + 4).
125− Render(content)
126−}
127−
128−func (q *quitDialogCmp) BindingKeys() []key.Binding {
129− return layout.KeyMapToSlice(helpKeys)
130−}
131−
132−func NewQuitCmp() QuitDialog {
133− return &quitDialogCmp{
134− selectedNo: true,
135− }
136−}
internal/tui/components/dialog/session.godeleted−230
@@ -1,230 +0,0 @@
1−package dialog
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7− "github.com/opencode-ai/opencode/internal/session"
8− "github.com/opencode-ai/opencode/internal/tui/layout"
9− "github.com/opencode-ai/opencode/internal/tui/styles"
10− "github.com/opencode-ai/opencode/internal/tui/theme"
11− "github.com/opencode-ai/opencode/internal/tui/util"
12−)
13−
14−// SessionSelectedMsg is sent when a session is selected
15−type SessionSelectedMsg struct {
16− Session session.Session
17−}
18−
19−// CloseSessionDialogMsg is sent when the session dialog is closed
20−type CloseSessionDialogMsg struct{}
21−
22−// SessionDialog interface for the session switching dialog
23−type SessionDialog interface {
24− tea.Model
25− layout.Bindings
26− SetSessions(sessions []session.Session)
27− SetSelectedSession(sessionID string)
28−}
29−
30−type sessionDialogCmp struct {
31− sessions []session.Session
32− selectedIdx int
33− width int
34− height int
35− selectedSessionID string
36−}
37−
38−type sessionKeyMap struct {
39− Up key.Binding
40− Down key.Binding
41− Enter key.Binding
42− Escape key.Binding
43− J key.Binding
44− K key.Binding
45−}
46−
47−var sessionKeys = sessionKeyMap{
48− Up: key.NewBinding(
49− key.WithKeys("up"),
50− key.WithHelp("↑", "previous session"),
51− ),
52− Down: key.NewBinding(
53− key.WithKeys("down"),
54− key.WithHelp("↓", "next session"),
55− ),
56− Enter: key.NewBinding(
57− key.WithKeys("enter"),
58− key.WithHelp("enter", "select session"),
59− ),
60− Escape: key.NewBinding(
61− key.WithKeys("esc"),
62− key.WithHelp("esc", "close"),
63− ),
64− J: key.NewBinding(
65− key.WithKeys("j"),
66− key.WithHelp("j", "next session"),
67− ),
68− K: key.NewBinding(
69− key.WithKeys("k"),
70− key.WithHelp("k", "previous session"),
71− ),
72−}
73−
74−func (s *sessionDialogCmp) Init() tea.Cmd {
75− return nil
76−}
77−
78−func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
79− switch msg := msg.(type) {
80− case tea.KeyMsg:
81− switch {
82− case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
83− if s.selectedIdx > 0 {
84− s.selectedIdx--
85− }
86− return s, nil
87− case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
88− if s.selectedIdx < len(s.sessions)-1 {
89− s.selectedIdx++
90− }
91− return s, nil
92− case key.Matches(msg, sessionKeys.Enter):
93− if len(s.sessions) > 0 {
94− return s, util.CmdHandler(SessionSelectedMsg{
95− Session: s.sessions[s.selectedIdx],
96− })
97− }
98− case key.Matches(msg, sessionKeys.Escape):
99− return s, util.CmdHandler(CloseSessionDialogMsg{})
100− }
101− case tea.WindowSizeMsg:
102− s.width = msg.Width
103− s.height = msg.Height
104− }
105− return s, nil
106−}
107−
108−func (s *sessionDialogCmp) View() string {
109− t := theme.CurrentTheme()
110− baseStyle := styles.BaseStyle()
111−
112− if len(s.sessions) == 0 {
113− return baseStyle.Padding(1, 2).
114− Border(lipgloss.RoundedBorder()).
115− BorderBackground(t.Background()).
116− BorderForeground(t.TextMuted()).
117− Width(40).
118− Render("No sessions available")
119− }
120−
121− // Calculate max width needed for session titles
122− maxWidth := 40 // Minimum width
123− for _, sess := range s.sessions {
124− if len(sess.Title) > maxWidth-4 { // Account for padding
125− maxWidth = len(sess.Title) + 4
126− }
127− }
128−
129− maxWidth = max(30, min(maxWidth, s.width-15)) // Limit width to avoid overflow
130−
131− // Limit height to avoid taking up too much screen space
132− maxVisibleSessions := min(10, len(s.sessions))
133−
134− // Build the session list
135− sessionItems := make([]string, 0, maxVisibleSessions)
136− startIdx := 0
137−
138− // If we have more sessions than can be displayed, adjust the start index
139− if len(s.sessions) > maxVisibleSessions {
140− // Center the selected item when possible
141− halfVisible := maxVisibleSessions / 2
142− if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
143− startIdx = s.selectedIdx - halfVisible
144− } else if s.selectedIdx >= len(s.sessions)-halfVisible {
145− startIdx = len(s.sessions) - maxVisibleSessions
146− }
147− }
148−
149− endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
150−
151− for i := startIdx; i < endIdx; i++ {
152− sess := s.sessions[i]
153− itemStyle := baseStyle.Width(maxWidth)
154−
155− if i == s.selectedIdx {
156− itemStyle = itemStyle.
157− Background(t.Primary()).
158− Foreground(t.Background()).
159− Bold(true)
160− }
161−
162− sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
163− }
164−
165− title := baseStyle.
166− Foreground(t.Primary()).
167− Bold(true).
168− Width(maxWidth).
169− Padding(0, 1).
170− Render("Switch Session")
171−
172− content := lipgloss.JoinVertical(
173− lipgloss.Left,
174− title,
175− baseStyle.Width(maxWidth).Render(""),
176− baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
177− baseStyle.Width(maxWidth).Render(""),
178− )
179−
180− return baseStyle.Padding(1, 2).
181− Border(lipgloss.RoundedBorder()).
182− BorderBackground(t.Background()).
183− BorderForeground(t.TextMuted()).
184− Width(lipgloss.Width(content) + 4).
185− Render(content)
186−}
187−
188−func (s *sessionDialogCmp) BindingKeys() []key.Binding {
189− return layout.KeyMapToSlice(sessionKeys)
190−}
191−
192−func (s *sessionDialogCmp) SetSessions(sessions []session.Session) {
193− s.sessions = sessions
194−
195− // If we have a selected session ID, find its index
196− if s.selectedSessionID != "" {
197− for i, sess := range sessions {
198− if sess.ID == s.selectedSessionID {
199− s.selectedIdx = i
200− return
201− }
202− }
203− }
204−
205− // Default to first session if selected not found
206− s.selectedIdx = 0
207−}
208−
209−func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
210− s.selectedSessionID = sessionID
211−
212− // Update the selected index if sessions are already loaded
213− if len(s.sessions) > 0 {
214− for i, sess := range s.sessions {
215− if sess.ID == sessionID {
216− s.selectedIdx = i
217− return
218− }
219− }
220− }
221−}
222−
223−// NewSessionDialogCmp creates a new session switching dialog
224−func NewSessionDialogCmp() SessionDialog {
225− return &sessionDialogCmp{
226− sessions: []session.Session{},
227− selectedIdx: 0,
228− selectedSessionID: "",
229− }
230−}
internal/tui/components/dialog/theme.godeleted−198
@@ -1,198 +0,0 @@
1−package dialog
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7− "github.com/opencode-ai/opencode/internal/tui/layout"
8− "github.com/opencode-ai/opencode/internal/tui/styles"
9− "github.com/opencode-ai/opencode/internal/tui/theme"
10− "github.com/opencode-ai/opencode/internal/tui/util"
11−)
12−
13−// ThemeChangedMsg is sent when the theme is changed
14−type ThemeChangedMsg struct {
15− ThemeName string
16−}
17−
18−// CloseThemeDialogMsg is sent when the theme dialog is closed
19−type CloseThemeDialogMsg struct{}
20−
21−// ThemeDialog interface for the theme switching dialog
22−type ThemeDialog interface {
23− tea.Model
24− layout.Bindings
25−}
26−
27−type themeDialogCmp struct {
28− themes []string
29− selectedIdx int
30− width int
31− height int
32− currentTheme string
33−}
34−
35−type themeKeyMap struct {
36− Up key.Binding
37− Down key.Binding
38− Enter key.Binding
39− Escape key.Binding
40− J key.Binding
41− K key.Binding
42−}
43−
44−var themeKeys = themeKeyMap{
45− Up: key.NewBinding(
46− key.WithKeys("up"),
47− key.WithHelp("↑", "previous theme"),
48− ),
49− Down: key.NewBinding(
50− key.WithKeys("down"),
51− key.WithHelp("↓", "next theme"),
52− ),
53− Enter: key.NewBinding(
54− key.WithKeys("enter"),
55− key.WithHelp("enter", "select theme"),
56− ),
57− Escape: key.NewBinding(
58− key.WithKeys("esc"),
59− key.WithHelp("esc", "close"),
60− ),
61− J: key.NewBinding(
62− key.WithKeys("j"),
63− key.WithHelp("j", "next theme"),
64− ),
65− K: key.NewBinding(
66− key.WithKeys("k"),
67− key.WithHelp("k", "previous theme"),
68− ),
69−}
70−
71−func (t *themeDialogCmp) Init() tea.Cmd {
72− // Load available themes and update selectedIdx based on current theme
73− t.themes = theme.AvailableThemes()
74− t.currentTheme = theme.CurrentThemeName()
75−
76− // Find the current theme in the list
77− for i, name := range t.themes {
78− if name == t.currentTheme {
79− t.selectedIdx = i
80− break
81− }
82− }
83−
84− return nil
85−}
86−
87−func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88− switch msg := msg.(type) {
89− case tea.KeyMsg:
90− switch {
91− case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
92− if t.selectedIdx > 0 {
93− t.selectedIdx--
94− }
95− return t, nil
96− case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J):
97− if t.selectedIdx < len(t.themes)-1 {
98− t.selectedIdx++
99− }
100− return t, nil
101− case key.Matches(msg, themeKeys.Enter):
102− if len(t.themes) > 0 {
103− previousTheme := theme.CurrentThemeName()
104− selectedTheme := t.themes[t.selectedIdx]
105− if previousTheme == selectedTheme {
106− return t, util.CmdHandler(CloseThemeDialogMsg{})
107− }
108− if err := theme.SetTheme(selectedTheme); err != nil {
109− return t, util.ReportError(err)
110− }
111− return t, util.CmdHandler(ThemeChangedMsg{
112− ThemeName: selectedTheme,
113− })
114− }
115− case key.Matches(msg, themeKeys.Escape):
116− return t, util.CmdHandler(CloseThemeDialogMsg{})
117− }
118− case tea.WindowSizeMsg:
119− t.width = msg.Width
120− t.height = msg.Height
121− }
122− return t, nil
123−}
124−
125−func (t *themeDialogCmp) View() string {
126− currentTheme := theme.CurrentTheme()
127− baseStyle := styles.BaseStyle()
128−
129− if len(t.themes) == 0 {
130− return baseStyle.Padding(1, 2).
131− Border(lipgloss.RoundedBorder()).
132− BorderBackground(currentTheme.Background()).
133− BorderForeground(currentTheme.TextMuted()).
134− Width(40).
135− Render("No themes available")
136− }
137−
138− // Calculate max width needed for theme names
139− maxWidth := 40 // Minimum width
140− for _, themeName := range t.themes {
141− if len(themeName) > maxWidth-4 { // Account for padding
142− maxWidth = len(themeName) + 4
143− }
144− }
145−
146− maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow
147−
148− // Build the theme list
149− themeItems := make([]string, 0, len(t.themes))
150− for i, themeName := range t.themes {
151− itemStyle := baseStyle.Width(maxWidth)
152−
153− if i == t.selectedIdx {
154− itemStyle = itemStyle.
155− Background(currentTheme.Primary()).
156− Foreground(currentTheme.Background()).
157− Bold(true)
158− }
159−
160− themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName))
161− }
162−
163− title := baseStyle.
164− Foreground(currentTheme.Primary()).
165− Bold(true).
166− Width(maxWidth).
167− Padding(0, 1).
168− Render("Select Theme")
169−
170− content := lipgloss.JoinVertical(
171− lipgloss.Left,
172− title,
173− baseStyle.Width(maxWidth).Render(""),
174− baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)),
175− baseStyle.Width(maxWidth).Render(""),
176− )
177−
178− return baseStyle.Padding(1, 2).
179− Border(lipgloss.RoundedBorder()).
180− BorderBackground(currentTheme.Background()).
181− BorderForeground(currentTheme.TextMuted()).
182− Width(lipgloss.Width(content) + 4).
183− Render(content)
184−}
185−
186−func (t *themeDialogCmp) BindingKeys() []key.Binding {
187− return layout.KeyMapToSlice(themeKeys)
188−}
189−
190−// NewThemeDialogCmp creates a new theme switching dialog
191−func NewThemeDialogCmp() ThemeDialog {
192− return &themeDialogCmp{
193− themes: []string{},
194− selectedIdx: 0,
195− currentTheme: "",
196− }
197−}
198−
internal/tui/components/dialogs/commands/arguments.gonew file+234
@@ -0,0 +1,234 @@
1+package commands
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/bubbles/v2/help"
8+ "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/textinput"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
13+ "github.com/charmbracelet/crush/internal/tui/util"
14+ "github.com/charmbracelet/lipgloss/v2"
15+)
16+
17+const (
18+ argumentsDialogID dialogs.DialogID = "arguments"
19+)
20+
21+// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
22+type ShowArgumentsDialogMsg struct {
23+ CommandID string
24+ Content string
25+ ArgNames []string
26+}
27+
28+// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
29+type CloseArgumentsDialogMsg struct {
30+ Submit bool
31+ CommandID string
32+ Content string
33+ Args map[string]string
34+}
35+
36+// CommandArgumentsDialog represents the commands dialog.
37+type CommandArgumentsDialog interface {
38+ dialogs.DialogModel
39+}
40+
41+type commandArgumentsDialogCmp struct {
42+ width int
43+ wWidth int // Width of the terminal window
44+ wHeight int // Height of the terminal window
45+
46+ inputs []textinput.Model
47+ focusIndex int
48+ keys ArgumentsDialogKeyMap
49+ commandID string
50+ content string
51+ argNames []string
52+ help help.Model
53+}
54+
55+func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog {
56+ t := styles.CurrentTheme()
57+ inputs := make([]textinput.Model, len(argNames))
58+
59+ for i, name := range argNames {
60+ ti := textinput.New()
61+ ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
62+ ti.SetWidth(40)
63+ ti.SetVirtualCursor(false)
64+ ti.Prompt = ""
65+
66+ ti.SetStyles(t.S().TextInput)
67+ // Only focus the first input initially
68+ if i == 0 {
69+ ti.Focus()
70+ } else {
71+ ti.Blur()
72+ }
73+
74+ inputs[i] = ti
75+ }
76+
77+ return &commandArgumentsDialogCmp{
78+ inputs: inputs,
79+ keys: DefaultArgumentsDialogKeyMap(),
80+ commandID: commandID,
81+ content: content,
82+ argNames: argNames,
83+ focusIndex: 0,
84+ width: 60,
85+ help: help.New(),
86+ }
87+}
88+
89+// Init implements CommandArgumentsDialog.
90+func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
91+ return nil
92+}
93+
94+// Update implements CommandArgumentsDialog.
95+func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96+ switch msg := msg.(type) {
97+ case tea.WindowSizeMsg:
98+ c.wWidth = msg.Width
99+ c.wHeight = msg.Height
100+ case tea.KeyPressMsg:
101+ switch {
102+ case key.Matches(msg, c.keys.Confirm):
103+ if c.focusIndex == len(c.inputs)-1 {
104+ content := c.content
105+ for i, name := range c.argNames {
106+ value := c.inputs[i].Value()
107+ placeholder := "$" + name
108+ content = strings.ReplaceAll(content, placeholder, value)
109+ }
110+ return c, tea.Sequence(
111+ util.CmdHandler(dialogs.CloseDialogMsg{}),
112+ util.CmdHandler(CommandRunCustomMsg{
113+ Content: content,
114+ }),
115+ )
116+ }
117+ // Otherwise, move to the next input
118+ c.inputs[c.focusIndex].Blur()
119+ c.focusIndex++
120+ c.inputs[c.focusIndex].Focus()
121+ case key.Matches(msg, c.keys.Next):
122+ // Move to the next input
123+ c.inputs[c.focusIndex].Blur()
124+ c.focusIndex = (c.focusIndex + 1) % len(c.inputs)
125+ c.inputs[c.focusIndex].Focus()
126+ case key.Matches(msg, c.keys.Previous):
127+ // Move to the previous input
128+ c.inputs[c.focusIndex].Blur()
129+ c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
130+ c.inputs[c.focusIndex].Focus()
131+
132+ default:
133+ var cmd tea.Cmd
134+ c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
135+ return c, cmd
136+ }
137+ }
138+ return c, nil
139+}
140+
141+// View implements CommandArgumentsDialog.
142+func (c *commandArgumentsDialogCmp) View() tea.View {
143+ t := styles.CurrentTheme()
144+ baseStyle := t.S().Base
145+
146+ title := lipgloss.NewStyle().
147+ Foreground(t.Primary).
148+ Bold(true).
149+ Padding(0, 1).
150+ Render("Command Arguments")
151+
152+ explanation := t.S().Text.
153+ Padding(0, 1).
154+ Render("This command requires arguments.")
155+
156+ // Create input fields for each argument
157+ inputFields := make([]string, len(c.inputs))
158+ for i, input := range c.inputs {
159+ // Highlight the label of the focused input
160+ labelStyle := baseStyle.
161+ Padding(1, 1, 0, 1)
162+
163+ if i == c.focusIndex {
164+ labelStyle = labelStyle.Foreground(t.FgBase).Bold(true)
165+ } else {
166+ labelStyle = labelStyle.Foreground(t.FgMuted)
167+ }
168+
169+ label := labelStyle.Render(c.argNames[i] + ":")
170+
171+ field := t.S().Text.
172+ Padding(0, 1).
173+ Render(input.View())
174+
175+ inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
176+ }
177+
178+ // Join all elements vertically
179+ elements := []string{title, explanation}
180+ elements = append(elements, inputFields...)
181+
182+ c.help.ShowAll = false
183+ helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
184+ elements = append(elements, "", helpText)
185+
186+ content := lipgloss.JoinVertical(
187+ lipgloss.Left,
188+ elements...,
189+ )
190+
191+ view := tea.NewView(
192+ baseStyle.Padding(1, 1, 0, 1).
193+ Border(lipgloss.RoundedBorder()).
194+ BorderForeground(t.BorderFocus).
195+ Width(c.width).
196+ Render(content),
197+ )
198+ cursor := c.inputs[c.focusIndex].Cursor()
199+ if cursor != nil {
200+ cursor = c.moveCursor(cursor)
201+ }
202+ view.SetCursor(cursor)
203+ return view
204+}
205+
206+func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
207+ row, col := c.Position()
208+ offset := row + 3 + (1+c.focusIndex)*3
209+ cursor.Y += offset
210+ cursor.X = cursor.X + col + 3
211+ return cursor
212+}
213+
214+func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
215+ t := styles.CurrentTheme()
216+ return t.S().Base.
217+ Width(c.width).
218+ Padding(1).
219+ Border(lipgloss.RoundedBorder()).
220+ BorderForeground(t.BorderFocus)
221+}
222+
223+func (c *commandArgumentsDialogCmp) Position() (int, int) {
224+ row := c.wHeight / 2
225+ row -= c.wHeight / 2
226+ col := c.wWidth / 2
227+ col -= c.width / 2
228+ return row, col
229+}
230+
231+// ID implements CommandArgumentsDialog.
232+func (c *commandArgumentsDialogCmp) ID() dialogs.DialogID {
233+ return argumentsDialogID
234+}
internal/tui/components/dialogs/commands/commands.gonew file+287
@@ -0,0 +1,287 @@
1+package commands
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/help"
5+ "github.com/charmbracelet/bubbles/v2/key"
6+ tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/lipgloss/v2"
8+
9+ "github.com/charmbracelet/crush/internal/tui/components/chat"
10+ "github.com/charmbracelet/crush/internal/tui/components/completions"
11+ "github.com/charmbracelet/crush/internal/tui/components/core"
12+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
13+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
15+ "github.com/charmbracelet/crush/internal/tui/util"
16+)
17+
18+const (
19+ CommandsDialogID dialogs.DialogID = "commands"
20+
21+ defaultWidth int = 70
22+)
23+
24+const (
25+ SystemCommands int = iota
26+ UserCommands
27+)
28+
29+// Command represents a command that can be executed
30+type Command struct {
31+ ID string
32+ Title string
33+ Description string
34+ Shortcut string // Optional shortcut for the command
35+ Handler func(cmd Command) tea.Cmd
36+}
37+
38+// CommandsDialog represents the commands dialog.
39+type CommandsDialog interface {
40+ dialogs.DialogModel
41+}
42+
43+type commandDialogCmp struct {
44+ width int
45+ wWidth int // Width of the terminal window
46+ wHeight int // Height of the terminal window
47+
48+ commandList list.ListModel
49+ keyMap CommandsDialogKeyMap
50+ help help.Model
51+ commandType int // SystemCommands or UserCommands
52+ userCommands []Command // User-defined commands
53+ sessionID string // Current session ID
54+}
55+
56+type (
57+ SwitchSessionsMsg struct{}
58+ SwitchModelMsg struct{}
59+ CompactMsg struct {
60+ SessionID string
61+ }
62+)
63+
64+func NewCommandDialog(sessionID string) CommandsDialog {
65+ listKeyMap := list.DefaultKeyMap()
66+ keyMap := DefaultCommandsDialogKeyMap()
67+
68+ listKeyMap.Down.SetEnabled(false)
69+ listKeyMap.Up.SetEnabled(false)
70+ listKeyMap.NDown.SetEnabled(false)
71+ listKeyMap.NUp.SetEnabled(false)
72+ listKeyMap.HalfPageDown.SetEnabled(false)
73+ listKeyMap.HalfPageUp.SetEnabled(false)
74+ listKeyMap.Home.SetEnabled(false)
75+ listKeyMap.End.SetEnabled(false)
76+
77+ listKeyMap.DownOneItem = keyMap.Next
78+ listKeyMap.UpOneItem = keyMap.Previous
79+
80+ t := styles.CurrentTheme()
81+ commandList := list.New(
82+ list.WithFilterable(true),
83+ list.WithKeyMap(listKeyMap),
84+ list.WithWrapNavigation(true),
85+ )
86+ help := help.New()
87+ help.Styles = t.S().Help
88+ return &commandDialogCmp{
89+ commandList: commandList,
90+ width: defaultWidth,
91+ keyMap: DefaultCommandsDialogKeyMap(),
92+ help: help,
93+ commandType: SystemCommands,
94+ sessionID: sessionID,
95+ }
96+}
97+
98+func (c *commandDialogCmp) Init() tea.Cmd {
99+ commands, err := LoadCustomCommands()
100+ if err != nil {
101+ return util.ReportError(err)
102+ }
103+
104+ c.userCommands = commands
105+ c.SetCommandType(c.commandType)
106+ return c.commandList.Init()
107+}
108+
109+func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
110+ switch msg := msg.(type) {
111+ case tea.WindowSizeMsg:
112+ c.wWidth = msg.Width
113+ c.wHeight = msg.Height
114+ return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
115+ case tea.KeyPressMsg:
116+ switch {
117+ case key.Matches(msg, c.keyMap.Select):
118+ selectedItemInx := c.commandList.SelectedIndex()
119+ if selectedItemInx == list.NoSelection {
120+ return c, nil // No item selected, do nothing
121+ }
122+ items := c.commandList.Items()
123+ selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
124+ return c, tea.Sequence(
125+ util.CmdHandler(dialogs.CloseDialogMsg{}),
126+ selectedItem.Handler(selectedItem),
127+ )
128+ case key.Matches(msg, c.keyMap.Tab):
129+ // Toggle command type between System and User commands
130+ if c.commandType == SystemCommands {
131+ return c, c.SetCommandType(UserCommands)
132+ } else {
133+ return c, c.SetCommandType(SystemCommands)
134+ }
135+ case key.Matches(msg, c.keyMap.Close):
136+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
137+ default:
138+ u, cmd := c.commandList.Update(msg)
139+ c.commandList = u.(list.ListModel)
140+ return c, cmd
141+ }
142+ }
143+ return c, nil
144+}
145+
146+func (c *commandDialogCmp) View() tea.View {
147+ t := styles.CurrentTheme()
148+ listView := c.commandList.View()
149+ radio := c.commandTypeRadio()
150+ content := lipgloss.JoinVertical(
151+ lipgloss.Left,
152+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
153+ listView.String(),
154+ "",
155+ t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
156+ )
157+ v := tea.NewView(c.style().Render(content))
158+ if listView.Cursor() != nil {
159+ c := c.moveCursor(listView.Cursor())
160+ v.SetCursor(c)
161+ }
162+ return v
163+}
164+
165+func (c *commandDialogCmp) commandTypeRadio() string {
166+ t := styles.CurrentTheme()
167+ choices := []string{"System", "User"}
168+ iconSelected := "◉"
169+ iconUnselected := "○"
170+ if c.commandType == SystemCommands {
171+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
172+ }
173+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
174+}
175+
176+func (c *commandDialogCmp) listWidth() int {
177+ return defaultWidth - 2 // 4 for padding
178+}
179+
180+func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
181+ c.commandType = commandType
182+
183+ var commands []Command
184+ if c.commandType == SystemCommands {
185+ commands = c.defaultCommands()
186+ } else {
187+ commands = c.userCommands
188+ }
189+
190+ commandItems := []util.Model{}
191+ for _, cmd := range commands {
192+ opts := []completions.CompletionOption{}
193+ if cmd.Shortcut != "" {
194+ opts = append(opts, completions.WithShortcut(cmd.Shortcut))
195+ }
196+ commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
197+ }
198+ return c.commandList.SetItems(commandItems)
199+}
200+
201+func (c *commandDialogCmp) listHeight() int {
202+ listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
203+ return min(listHeigh, c.wHeight/2)
204+}
205+
206+func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
207+ row, col := c.Position()
208+ offset := row + 3
209+ cursor.Y += offset
210+ cursor.X = cursor.X + col + 2
211+ return cursor
212+}
213+
214+func (c *commandDialogCmp) style() lipgloss.Style {
215+ t := styles.CurrentTheme()
216+ return t.S().Base.
217+ Width(c.width).
218+ Border(lipgloss.RoundedBorder()).
219+ BorderForeground(t.BorderFocus)
220+}
221+
222+func (c *commandDialogCmp) Position() (int, int) {
223+ row := c.wHeight/4 - 2 // just a bit above the center
224+ col := c.wWidth / 2
225+ col -= c.width / 2
226+ return row, col
227+}
228+
229+func (c *commandDialogCmp) defaultCommands() []Command {
230+ commands := []Command{
231+ {
232+ ID: "init",
233+ Title: "Initialize Project",
234+ Description: "Create/Update the Crush.md memory file",
235+ Handler: func(cmd Command) tea.Cmd {
236+ prompt := `Please analyze this codebase and create a Crush.md file containing:
237+ 1. Build/lint/test commands - especially for running a single test
238+ 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
239+
240+ 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.
241+ If there's already a crush.md, improve it.
242+ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
243+ return util.CmdHandler(chat.SendMsg{
244+ Text: prompt,
245+ })
246+ },
247+ },
248+ }
249+
250+ // Only show compact command if there's an active session
251+ if c.sessionID != "" {
252+ commands = append(commands, Command{
253+ ID: "compact",
254+ Title: "Compact Session",
255+ Description: "Summarize the current session and create a new one with the summary",
256+ Handler: func(cmd Command) tea.Cmd {
257+ return util.CmdHandler(CompactMsg{
258+ SessionID: c.sessionID,
259+ })
260+ },
261+ })
262+ }
263+
264+ return append(commands, []Command{
265+ {
266+ ID: "switch_session",
267+ Title: "Switch Session",
268+ Description: "Switch to a different session",
269+ Shortcut: "ctrl+s",
270+ Handler: func(cmd Command) tea.Cmd {
271+ return util.CmdHandler(SwitchSessionsMsg{})
272+ },
273+ },
274+ {
275+ ID: "switch_model",
276+ Title: "Switch Model",
277+ Description: "Switch to a different model",
278+ Handler: func(cmd Command) tea.Cmd {
279+ return util.CmdHandler(SwitchModelMsg{})
280+ },
281+ },
282+ }...)
283+}
284+
285+func (c *commandDialogCmp) ID() dialogs.DialogID {
286+ return CommandsDialogID
287+}
internal/tui/components/dialogs/commands/item.gonew file+57
@@ -0,0 +1,57 @@
1+package commands
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea/v2"
5+ "github.com/charmbracelet/crush/internal/tui/components/core"
6+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
7+ "github.com/charmbracelet/crush/internal/tui/layout"
8+ "github.com/charmbracelet/crush/internal/tui/styles"
9+ "github.com/charmbracelet/crush/internal/tui/util"
10+ "github.com/charmbracelet/x/ansi"
11+)
12+
13+type ItemSection interface {
14+ util.Model
15+ layout.Sizeable
16+ list.SectionHeader
17+}
18+type itemSectionModel struct {
19+ width int
20+ title string
21+ noPadding bool // No padding for the section header
22+}
23+
24+func NewItemSection(title string) ItemSection {
25+ return &itemSectionModel{
26+ title: title,
27+ }
28+}
29+
30+func (m *itemSectionModel) Init() tea.Cmd {
31+ return nil
32+}
33+
34+func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
35+ return m, nil
36+}
37+
38+func (m *itemSectionModel) View() tea.View {
39+ t := styles.CurrentTheme()
40+ title := ansi.Truncate(m.title, m.width-2, "…")
41+ style := t.S().Base.Padding(1, 1, 0, 1)
42+ title = t.S().Muted.Render(title)
43+ return tea.NewView(style.Render(core.Section(title, m.width-2)))
44+}
45+
46+func (m *itemSectionModel) GetSize() (int, int) {
47+ return m.width, 1
48+}
49+
50+func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
51+ m.width = width
52+ return nil
53+}
54+
55+func (m *itemSectionModel) IsSectionHeader() bool {
56+ return true
57+}
internal/tui/components/dialogs/commands/keys.gonew file+107
@@ -0,0 +1,107 @@
1+package commands
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type CommandsDialogKeyMap struct {
9+ Select,
10+ Next,
11+ Previous,
12+ Tab,
13+ Close key.Binding
14+}
15+
16+func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
17+ return CommandsDialogKeyMap{
18+ Select: key.NewBinding(
19+ key.WithKeys("enter", "ctrl+y"),
20+ key.WithHelp("enter", "confirm"),
21+ ),
22+ Next: key.NewBinding(
23+ key.WithKeys("down", "ctrl+n"),
24+ key.WithHelp("↓", "next item"),
25+ ),
26+ Previous: key.NewBinding(
27+ key.WithKeys("up", "ctrl+p"),
28+ key.WithHelp("↑", "previous item"),
29+ ),
30+ Tab: key.NewBinding(
31+ key.WithKeys("tab"),
32+ key.WithHelp("tab", "switch selection"),
33+ ),
34+ Close: key.NewBinding(
35+ key.WithKeys("esc"),
36+ key.WithHelp("esc", "cancel"),
37+ ),
38+ }
39+}
40+
41+// FullHelp implements help.KeyMap.
42+func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding {
43+ m := [][]key.Binding{}
44+ slice := layout.KeyMapToSlice(k)
45+ for i := 0; i < len(slice); i += 4 {
46+ end := min(i+4, len(slice))
47+ m = append(m, slice[i:end])
48+ }
49+ return m
50+}
51+
52+// ShortHelp implements help.KeyMap.
53+func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
54+ return []key.Binding{
55+ k.Tab,
56+ key.NewBinding(
57+ key.WithKeys("down", "up"),
58+ key.WithHelp("↑↓", "choose"),
59+ ),
60+ k.Select,
61+ k.Close,
62+ }
63+}
64+
65+type ArgumentsDialogKeyMap struct {
66+ Confirm key.Binding
67+ Next key.Binding
68+ Previous key.Binding
69+}
70+
71+func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
72+ return ArgumentsDialogKeyMap{
73+ Confirm: key.NewBinding(
74+ key.WithKeys("enter"),
75+ key.WithHelp("enter", "confirm"),
76+ ),
77+
78+ Next: key.NewBinding(
79+ key.WithKeys("tab", "down"),
80+ key.WithHelp("tab/↓", "next"),
81+ ),
82+ Previous: key.NewBinding(
83+ key.WithKeys("shift+tab", "up"),
84+ key.WithHelp("shift+tab/↑", "previous"),
85+ ),
86+ }
87+}
88+
89+// FullHelp implements help.KeyMap.
90+func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding {
91+ m := [][]key.Binding{}
92+ slice := layout.KeyMapToSlice(k)
93+ for i := 0; i < len(slice); i += 4 {
94+ end := min(i+4, len(slice))
95+ m = append(m, slice[i:end])
96+ }
97+ return m
98+}
99+
100+// ShortHelp implements help.KeyMap.
101+func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
102+ return []key.Binding{
103+ k.Confirm,
104+ k.Next,
105+ k.Previous,
106+ }
107+}
internal/tui/components/dialogs/commands/loader.gonew file+202
@@ -0,0 +1,202 @@
1+package commands
2+
3+import (
4+ "fmt"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "regexp"
9+ "strings"
10+
11+ tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/tui/util"
14+)
15+
16+const (
17+ UserCommandPrefix = "user:"
18+ ProjectCommandPrefix = "project:"
19+)
20+
21+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
22+
23+type commandLoader struct {
24+ sources []commandSource
25+}
26+
27+type commandSource struct {
28+ path string
29+ prefix string
30+}
31+
32+func LoadCustomCommands() ([]Command, error) {
33+ cfg := config.Get()
34+ if cfg == nil {
35+ return nil, fmt.Errorf("config not loaded")
36+ }
37+
38+ loader := &commandLoader{
39+ sources: buildCommandSources(cfg),
40+ }
41+
42+ return loader.loadAll()
43+}
44+
45+func buildCommandSources(cfg *config.Config) []commandSource {
46+ var sources []commandSource
47+
48+ // XDG config directory
49+ if dir := getXDGCommandsDir(); dir != "" {
50+ sources = append(sources, commandSource{
51+ path: dir,
52+ prefix: UserCommandPrefix,
53+ })
54+ }
55+
56+ // Home directory
57+ if home, err := os.UserHomeDir(); err == nil {
58+ sources = append(sources, commandSource{
59+ path: filepath.Join(home, ".crush", "commands"),
60+ prefix: UserCommandPrefix,
61+ })
62+ }
63+
64+ // Project directory
65+ sources = append(sources, commandSource{
66+ path: filepath.Join(cfg.Data.Directory, "commands"),
67+ prefix: ProjectCommandPrefix,
68+ })
69+
70+ return sources
71+}
72+
73+func getXDGCommandsDir() string {
74+ xdgHome := os.Getenv("XDG_CONFIG_HOME")
75+ if xdgHome == "" {
76+ if home, err := os.UserHomeDir(); err == nil {
77+ xdgHome = filepath.Join(home, ".config")
78+ }
79+ }
80+ if xdgHome != "" {
81+ return filepath.Join(xdgHome, "crush", "commands")
82+ }
83+ return ""
84+}
85+
86+func (l *commandLoader) loadAll() ([]Command, error) {
87+ var commands []Command
88+
89+ for _, source := range l.sources {
90+ if cmds, err := l.loadFromSource(source); err == nil {
91+ commands = append(commands, cmds...)
92+ }
93+ }
94+
95+ return commands, nil
96+}
97+
98+func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) {
99+ if err := ensureDir(source.path); err != nil {
100+ return nil, err
101+ }
102+
103+ var commands []Command
104+
105+ err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
106+ if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
107+ return err
108+ }
109+
110+ cmd, err := l.loadCommand(path, source.path, source.prefix)
111+ if err != nil {
112+ return nil // Skip invalid files
113+ }
114+
115+ commands = append(commands, cmd)
116+ return nil
117+ })
118+
119+ return commands, err
120+}
121+
122+func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) {
123+ content, err := os.ReadFile(path)
124+ if err != nil {
125+ return Command{}, err
126+ }
127+
128+ id := buildCommandID(path, baseDir, prefix)
129+
130+ return Command{
131+ ID: id,
132+ Title: id,
133+ Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)),
134+ Handler: createCommandHandler(id, string(content)),
135+ }, nil
136+}
137+
138+func buildCommandID(path, baseDir, prefix string) string {
139+ relPath, _ := filepath.Rel(baseDir, path)
140+ parts := strings.Split(relPath, string(filepath.Separator))
141+
142+ // Remove .md extension from last part
143+ if len(parts) > 0 {
144+ lastIdx := len(parts) - 1
145+ parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
146+ }
147+
148+ return prefix + strings.Join(parts, ":")
149+}
150+
151+func createCommandHandler(id string, content string) func(Command) tea.Cmd {
152+ return func(cmd Command) tea.Cmd {
153+ args := extractArgNames(content)
154+
155+ if len(args) > 0 {
156+ return util.CmdHandler(ShowArgumentsDialogMsg{
157+ CommandID: id,
158+ Content: content,
159+ ArgNames: args,
160+ })
161+ }
162+
163+ return util.CmdHandler(CommandRunCustomMsg{
164+ Content: content,
165+ })
166+ }
167+}
168+
169+func extractArgNames(content string) []string {
170+ matches := namedArgPattern.FindAllStringSubmatch(content, -1)
171+ if len(matches) == 0 {
172+ return nil
173+ }
174+
175+ seen := make(map[string]bool)
176+ var args []string
177+
178+ for _, match := range matches {
179+ arg := match[1]
180+ if !seen[arg] {
181+ seen[arg] = true
182+ args = append(args, arg)
183+ }
184+ }
185+
186+ return args
187+}
188+
189+func ensureDir(path string) error {
190+ if _, err := os.Stat(path); os.IsNotExist(err) {
191+ return os.MkdirAll(path, 0o755)
192+ }
193+ return nil
194+}
195+
196+func isMarkdownFile(name string) bool {
197+ return strings.HasSuffix(strings.ToLower(name), ".md")
198+}
199+
200+type CommandRunCustomMsg struct {
201+ Content string
202+}
internal/tui/components/dialogs/compact/compact.gonew file+265
@@ -0,0 +1,265 @@
1+package compact
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/bubbles/v2/key"
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/lipgloss/v2"
9+
10+ "github.com/charmbracelet/crush/internal/llm/agent"
11+ "github.com/charmbracelet/crush/internal/tui/components/core"
12+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
13+ "github.com/charmbracelet/crush/internal/tui/styles"
14+ "github.com/charmbracelet/crush/internal/tui/util"
15+)
16+
17+const CompactDialogID dialogs.DialogID = "compact"
18+
19+// CompactDialog interface for the session compact dialog
20+type CompactDialog interface {
21+ dialogs.DialogModel
22+}
23+
24+type compactDialogCmp struct {
25+ wWidth, wHeight int
26+ width, height int
27+ selected int
28+ keyMap KeyMap
29+ sessionID string
30+ state compactState
31+ progress string
32+ agent agent.Service
33+ noAsk bool // If true, skip confirmation dialog
34+}
35+
36+type compactState int
37+
38+const (
39+ stateConfirm compactState = iota
40+ stateCompacting
41+ stateError
42+)
43+
44+// NewCompactDialogCmp creates a new session compact dialog
45+func NewCompactDialogCmp(agent agent.Service, sessionID string, noAsk bool) CompactDialog {
46+ return &compactDialogCmp{
47+ sessionID: sessionID,
48+ keyMap: DefaultKeyMap(),
49+ state: stateConfirm,
50+ selected: 0,
51+ agent: agent,
52+ noAsk: noAsk,
53+ }
54+}
55+
56+func (c *compactDialogCmp) Init() tea.Cmd {
57+ if c.noAsk {
58+ // If noAsk is true, skip confirmation and start compaction immediately
59+ return c.startCompaction()
60+ }
61+ return nil
62+}
63+
64+func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65+ switch msg := msg.(type) {
66+ case tea.WindowSizeMsg:
67+ c.wWidth = msg.Width
68+ c.wHeight = msg.Height
69+ cmd := c.SetSize()
70+ return c, cmd
71+
72+ case tea.KeyPressMsg:
73+ switch c.state {
74+ case stateConfirm:
75+ switch {
76+ case key.Matches(msg, c.keyMap.ChangeSelection):
77+ c.selected = (c.selected + 1) % 2
78+ return c, nil
79+ case key.Matches(msg, c.keyMap.Select):
80+ if c.selected == 0 {
81+ return c, c.startCompaction()
82+ } else {
83+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
84+ }
85+ case key.Matches(msg, c.keyMap.Y):
86+ return c, c.startCompaction()
87+ case key.Matches(msg, c.keyMap.N):
88+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
89+ case key.Matches(msg, c.keyMap.Close):
90+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
91+ }
92+ case stateCompacting:
93+ switch {
94+ case key.Matches(msg, c.keyMap.Close):
95+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
96+ }
97+ case stateError:
98+ switch {
99+ case key.Matches(msg, c.keyMap.Select):
100+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
101+ case key.Matches(msg, c.keyMap.Close):
102+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
103+ }
104+ }
105+
106+ case agent.AgentEvent:
107+ if msg.Type == agent.AgentEventTypeSummarize {
108+ if msg.Error != nil {
109+ c.state = stateError
110+ c.progress = "Error: " + msg.Error.Error()
111+ } else if msg.Done {
112+ return c, util.CmdHandler(
113+ dialogs.CloseDialogMsg{},
114+ )
115+ } else {
116+ c.progress = msg.Progress
117+ }
118+ }
119+ return c, nil
120+ }
121+
122+ return c, nil
123+}
124+
125+func (c *compactDialogCmp) startCompaction() tea.Cmd {
126+ c.state = stateCompacting
127+ c.progress = "Starting summarization..."
128+ return func() tea.Msg {
129+ err := c.agent.Summarize(context.Background(), c.sessionID)
130+ if err != nil {
131+ c.state = stateError
132+ c.progress = "Error: " + err.Error()
133+ }
134+ return nil
135+ }
136+}
137+
138+func (c *compactDialogCmp) renderButtons() string {
139+ t := styles.CurrentTheme()
140+ baseStyle := t.S().Base
141+
142+ buttons := []core.ButtonOpts{
143+ {
144+ Text: "Yes",
145+ UnderlineIndex: 0, // "Y"
146+ Selected: c.selected == 0,
147+ },
148+ {
149+ Text: "No",
150+ UnderlineIndex: 0, // "N"
151+ Selected: c.selected == 1,
152+ },
153+ }
154+
155+ content := core.SelectableButtons(buttons, " ")
156+
157+ return baseStyle.AlignHorizontal(lipgloss.Right).Width(c.width - 4).Render(content)
158+}
159+
160+func (c *compactDialogCmp) renderContent() string {
161+ t := styles.CurrentTheme()
162+ baseStyle := t.S().Base
163+
164+ switch c.state {
165+ case stateConfirm:
166+ explanation := t.S().Text.
167+ Width(c.width - 4).
168+ 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.")
169+
170+ question := t.S().Text.
171+ Width(c.width - 4).
172+ Render("Do you want to continue?")
173+
174+ return baseStyle.Render(lipgloss.JoinVertical(
175+ lipgloss.Left,
176+ explanation,
177+ "",
178+ question,
179+ ))
180+ case stateCompacting:
181+ return baseStyle.Render(lipgloss.JoinVertical(
182+ lipgloss.Left,
183+ c.progress,
184+ "",
185+ "Please wait...",
186+ ))
187+ case stateError:
188+ return baseStyle.Render(lipgloss.JoinVertical(
189+ lipgloss.Left,
190+ c.progress,
191+ "",
192+ "Press Enter to close",
193+ ))
194+ }
195+ return ""
196+}
197+
198+func (c *compactDialogCmp) render() string {
199+ t := styles.CurrentTheme()
200+ baseStyle := t.S().Base
201+
202+ var title string
203+ switch c.state {
204+ case stateConfirm:
205+ title = "Compact Session"
206+ case stateCompacting:
207+ title = "Compacting Session"
208+ case stateError:
209+ title = "Compact Failed"
210+ }
211+
212+ titleView := core.Title(title, c.width-4)
213+ content := c.renderContent()
214+
215+ var dialogContent string
216+ if c.state == stateConfirm {
217+ buttons := c.renderButtons()
218+ dialogContent = lipgloss.JoinVertical(
219+ lipgloss.Top,
220+ titleView,
221+ "",
222+ content,
223+ "",
224+ buttons,
225+ "",
226+ )
227+ } else {
228+ dialogContent = lipgloss.JoinVertical(
229+ lipgloss.Top,
230+ titleView,
231+ "",
232+ content,
233+ "",
234+ )
235+ }
236+
237+ return baseStyle.
238+ Padding(0, 1).
239+ Border(lipgloss.RoundedBorder()).
240+ BorderForeground(t.BorderFocus).
241+ Width(c.width).
242+ Render(dialogContent)
243+}
244+
245+func (c *compactDialogCmp) View() tea.View {
246+ return tea.NewView(c.render())
247+}
248+
249+// SetSize sets the size of the component.
250+func (c *compactDialogCmp) SetSize() tea.Cmd {
251+ c.width = min(90, c.wWidth)
252+ c.height = min(15, c.wHeight)
253+ return nil
254+}
255+
256+func (c *compactDialogCmp) Position() (int, int) {
257+ row := (c.wHeight / 2) - (c.height / 2)
258+ col := (c.wWidth / 2) - (c.width / 2)
259+ return row, col
260+}
261+
262+// ID implements CompactDialog.
263+func (c *compactDialogCmp) ID() dialogs.DialogID {
264+ return CompactDialogID
265+}
internal/tui/components/dialogs/compact/keys.gonew file+61
@@ -0,0 +1,61 @@
1+package compact
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+// KeyMap defines the key bindings for the compact dialog.
9+type KeyMap struct {
10+ ChangeSelection key.Binding
11+ Select key.Binding
12+ Y key.Binding
13+ N key.Binding
14+ Close key.Binding
15+}
16+
17+// DefaultKeyMap returns the default key bindings for the compact dialog.
18+func DefaultKeyMap() KeyMap {
19+ return KeyMap{
20+ ChangeSelection: key.NewBinding(
21+ key.WithKeys("tab", "left", "right", "h", "l"),
22+ key.WithHelp("tab/←/→", "toggle selection"),
23+ ),
24+ Select: key.NewBinding(
25+ key.WithKeys("enter"),
26+ key.WithHelp("enter", "confirm"),
27+ ),
28+ Y: key.NewBinding(
29+ key.WithKeys("y"),
30+ key.WithHelp("y", "yes"),
31+ ),
32+ N: key.NewBinding(
33+ key.WithKeys("n"),
34+ key.WithHelp("n", "no"),
35+ ),
36+ Close: key.NewBinding(
37+ key.WithKeys("esc"),
38+ key.WithHelp("esc", "cancel"),
39+ ),
40+ }
41+}
42+
43+// FullHelp implements help.KeyMap.
44+func (k KeyMap) FullHelp() [][]key.Binding {
45+ m := [][]key.Binding{}
46+ slice := layout.KeyMapToSlice(k)
47+ for i := 0; i < len(slice); i += 4 {
48+ end := min(i+4, len(slice))
49+ m = append(m, slice[i:end])
50+ }
51+ return m
52+}
53+
54+// ShortHelp implements help.KeyMap.
55+func (k KeyMap) ShortHelp() []key.Binding {
56+ return []key.Binding{
57+ k.ChangeSelection,
58+ k.Select,
59+ k.Close,
60+ }
61+}
internal/tui/components/dialogs/dialogs.gonew file+162
@@ -0,0 +1,162 @@
1+package dialogs
2+
3+import (
4+ "slices"
5+
6+ tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/tui/util"
8+ "github.com/charmbracelet/lipgloss/v2"
9+)
10+
11+type DialogID string
12+
13+// DialogModel represents a dialog component that can be displayed.
14+type DialogModel interface {
15+ util.Model
16+ Position() (int, int)
17+ ID() DialogID
18+}
19+
20+// CloseCallback allows dialogs to perform cleanup when closed.
21+type CloseCallback interface {
22+ Close() tea.Cmd
23+}
24+
25+// OpenDialogMsg is sent to open a new dialog with specified dimensions.
26+type OpenDialogMsg struct {
27+ Model DialogModel
28+}
29+
30+// CloseDialogMsg is sent to close the topmost dialog.
31+type CloseDialogMsg struct{}
32+
33+// DialogCmp manages a stack of dialogs with keyboard navigation.
34+type DialogCmp interface {
35+ tea.Model
36+
37+ Dialogs() []DialogModel
38+ HasDialogs() bool
39+ GetLayers() []*lipgloss.Layer
40+ ActiveView() *tea.View
41+ ActiveDialogId() DialogID
42+}
43+
44+type dialogCmp struct {
45+ width, height int
46+ dialogs []DialogModel
47+ idMap map[DialogID]int
48+ keyMap KeyMap
49+}
50+
51+// NewDialogCmp creates a new dialog manager.
52+func NewDialogCmp() DialogCmp {
53+ return dialogCmp{
54+ dialogs: []DialogModel{},
55+ keyMap: DefaultKeyMap(),
56+ idMap: make(map[DialogID]int),
57+ }
58+}
59+
60+func (d dialogCmp) Init() tea.Cmd {
61+ return nil
62+}
63+
64+// Update handles dialog lifecycle and forwards messages to the active dialog.
65+func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
66+ switch msg := msg.(type) {
67+ case tea.WindowSizeMsg:
68+ var cmds []tea.Cmd
69+ d.width = msg.Width
70+ d.height = msg.Height
71+ for i := range d.dialogs {
72+ u, cmd := d.dialogs[i].Update(msg)
73+ d.dialogs[i] = u.(DialogModel)
74+ cmds = append(cmds, cmd)
75+ }
76+ return d, tea.Batch(cmds...)
77+ case OpenDialogMsg:
78+ return d.handleOpen(msg)
79+ case CloseDialogMsg:
80+ if len(d.dialogs) == 0 {
81+ return d, nil
82+ }
83+ inx := len(d.dialogs) - 1
84+ dialog := d.dialogs[inx]
85+ delete(d.idMap, dialog.ID())
86+ d.dialogs = d.dialogs[:len(d.dialogs)-1]
87+ if closeable, ok := dialog.(CloseCallback); ok {
88+ return d, closeable.Close()
89+ }
90+ return d, nil
91+ }
92+ if d.HasDialogs() {
93+ lastIndex := len(d.dialogs) - 1
94+ u, cmd := d.dialogs[lastIndex].Update(msg)
95+ d.dialogs[lastIndex] = u.(DialogModel)
96+ return d, cmd
97+ }
98+ return d, nil
99+}
100+
101+func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
102+ if d.HasDialogs() {
103+ dialog := d.dialogs[len(d.dialogs)-1]
104+ if dialog.ID() == msg.Model.ID() {
105+ return d, nil // Do not open a dialog if it's already the topmost one
106+ }
107+ if dialog.ID() == "quit" {
108+ return d, nil // Do not open dialogs on top of quit
109+ }
110+ }
111+ // if the dialog is already in the stack make it the last item
112+ if _, ok := d.idMap[msg.Model.ID()]; ok {
113+ existing := d.dialogs[d.idMap[msg.Model.ID()]]
114+ // Reuse the model so we keep the state
115+ msg.Model = existing
116+ d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
117+ }
118+ d.idMap[msg.Model.ID()] = len(d.dialogs)
119+ d.dialogs = append(d.dialogs, msg.Model)
120+ var cmds []tea.Cmd
121+ cmd := msg.Model.Init()
122+ cmds = append(cmds, cmd)
123+ _, cmd = msg.Model.Update(tea.WindowSizeMsg{
124+ Width: d.width,
125+ Height: d.height,
126+ })
127+ cmds = append(cmds, cmd)
128+ return d, tea.Batch(cmds...)
129+}
130+
131+func (d dialogCmp) Dialogs() []DialogModel {
132+ return d.dialogs
133+}
134+
135+func (d dialogCmp) ActiveView() *tea.View {
136+ if len(d.dialogs) == 0 {
137+ return nil
138+ }
139+ view := d.dialogs[len(d.dialogs)-1].View()
140+ return &view
141+}
142+
143+func (d dialogCmp) ActiveDialogId() DialogID {
144+ if len(d.dialogs) == 0 {
145+ return ""
146+ }
147+ return d.dialogs[len(d.dialogs)-1].ID()
148+}
149+
150+func (d dialogCmp) GetLayers() []*lipgloss.Layer {
151+ layers := []*lipgloss.Layer{}
152+ for _, dialog := range d.Dialogs() {
153+ dialogView := dialog.View().String()
154+ row, col := dialog.Position()
155+ layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
156+ }
157+ return layers
158+}
159+
160+func (d dialogCmp) HasDialogs() bool {
161+ return len(d.dialogs) > 0
162+}
internal/tui/components/dialogs/filepicker/filepicker.gonew file+229
@@ -0,0 +1,229 @@
1+package filepicker
2+
3+import (
4+ "fmt"
5+ "net/http"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "github.com/charmbracelet/bubbles/v2/filepicker"
11+ "github.com/charmbracelet/bubbles/v2/help"
12+ "github.com/charmbracelet/bubbles/v2/key"
13+ tea "github.com/charmbracelet/bubbletea/v2"
14+ "github.com/charmbracelet/crush/internal/logging"
15+ "github.com/charmbracelet/crush/internal/message"
16+ "github.com/charmbracelet/crush/internal/tui/components/core"
17+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
18+ "github.com/charmbracelet/crush/internal/tui/components/image"
19+ "github.com/charmbracelet/crush/internal/tui/styles"
20+ "github.com/charmbracelet/crush/internal/tui/util"
21+ "github.com/charmbracelet/lipgloss/v2"
22+)
23+
24+const (
25+ maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
26+ FilePickerID = "filepicker"
27+ fileSelectionHight = 10
28+)
29+
30+type FilePickedMsg struct {
31+ Attachment message.Attachment
32+}
33+
34+type FilePicker interface {
35+ dialogs.DialogModel
36+}
37+
38+type model struct {
39+ wWidth int
40+ wHeight int
41+ width int
42+ filePicker filepicker.Model
43+ highlightedFile string
44+ image image.Model
45+ keyMap KeyMap
46+ help help.Model
47+}
48+
49+func NewFilePickerCmp() FilePicker {
50+ t := styles.CurrentTheme()
51+ fp := filepicker.New()
52+ fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
53+ fp.CurrentDirectory, _ = os.UserHomeDir()
54+ fp.ShowPermissions = false
55+ fp.ShowSize = false
56+ fp.AutoHeight = false
57+ fp.Styles = t.S().FilePicker
58+ fp.Cursor = ""
59+ fp.SetHeight(fileSelectionHight)
60+
61+ image := image.New(1, 1, "")
62+
63+ help := help.New()
64+ help.Styles = t.S().Help
65+ return &model{
66+ filePicker: fp,
67+ image: image,
68+ keyMap: DefaultKeyMap(),
69+ help: help,
70+ }
71+}
72+
73+func (m *model) Init() tea.Cmd {
74+ return m.filePicker.Init()
75+}
76+
77+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78+ switch msg := msg.(type) {
79+ case tea.WindowSizeMsg:
80+ m.wWidth = msg.Width
81+ m.wHeight = msg.Height
82+ m.width = min(70, m.wWidth)
83+ styles := m.filePicker.Styles
84+ styles.Directory = styles.Directory.Width(m.width - 4)
85+ styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
86+ styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
87+ styles.File = styles.File.Width(m.width)
88+ m.filePicker.Styles = styles
89+ return m, nil
90+ case tea.KeyPressMsg:
91+ if key.Matches(msg, m.keyMap.Close) {
92+ return m, util.CmdHandler(dialogs.CloseDialogMsg{})
93+ }
94+ if key.Matches(msg, m.filePicker.KeyMap.Back) {
95+ // make sure we don't go back if we are at the home directory
96+ homeDir, _ := os.UserHomeDir()
97+ if m.filePicker.CurrentDirectory == homeDir {
98+ return m, nil
99+ }
100+ }
101+ }
102+
103+ var cmd tea.Cmd
104+ var cmds []tea.Cmd
105+ m.filePicker, cmd = m.filePicker.Update(msg)
106+ cmds = append(cmds, cmd)
107+ if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
108+ w, h := m.imagePreviewSize()
109+ cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
110+ cmds = append(cmds, cmd)
111+ }
112+ m.highlightedFile = m.currentImage()
113+
114+ // Did the user select a file?
115+ if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
116+ // Get the path of the selected file.
117+ return m, tea.Sequence(
118+ util.CmdHandler(dialogs.CloseDialogMsg{}),
119+ func() tea.Msg {
120+ isFileLarge, err := ValidateFileSize(path, maxAttachmentSize)
121+ if err != nil {
122+ logging.ErrorPersist("unable to read the image")
123+ return nil
124+ }
125+ if isFileLarge {
126+ logging.ErrorPersist("file too large, max 5MB")
127+ return nil
128+ }
129+
130+ content, err := os.ReadFile(path)
131+ if err != nil {
132+ logging.ErrorPersist("Unable read selected file")
133+ return nil
134+ }
135+
136+ mimeBufferSize := min(512, len(content))
137+ mimeType := http.DetectContentType(content[:mimeBufferSize])
138+ fileName := filepath.Base(path)
139+ attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
140+ return FilePickedMsg{
141+ Attachment: attachment,
142+ }
143+ },
144+ )
145+ }
146+ m.image, cmd = m.image.Update(msg)
147+ cmds = append(cmds, cmd)
148+ return m, tea.Batch(cmds...)
149+}
150+
151+func (m *model) View() tea.View {
152+ t := styles.CurrentTheme()
153+
154+ content := lipgloss.JoinVertical(
155+ lipgloss.Left,
156+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
157+ m.imagePreview(),
158+ m.filePicker.View(),
159+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
160+ )
161+ return tea.NewView(m.style().Render(content))
162+}
163+
164+func (m *model) currentImage() string {
165+ for _, ext := range m.filePicker.AllowedTypes {
166+ if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) {
167+ return m.filePicker.HighlightedPath()
168+ }
169+ }
170+ return ""
171+}
172+
173+func (m *model) imagePreview() string {
174+ t := styles.CurrentTheme()
175+ w, h := m.imagePreviewSize()
176+ if m.currentImage() == "" {
177+ imgPreview := t.S().Base.
178+ Width(w).
179+ Height(h).
180+ Background(t.BgOverlay)
181+
182+ return m.imagePreviewStyle().Render(imgPreview.Render())
183+ }
184+
185+ return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
186+}
187+
188+func (m *model) imagePreviewStyle() lipgloss.Style {
189+ t := styles.CurrentTheme()
190+ return t.S().Base.Padding(1, 1, 1, 1)
191+}
192+
193+func (m *model) imagePreviewSize() (int, int) {
194+ return m.width - 4, min(20, m.wHeight/2)
195+}
196+
197+func (m *model) style() lipgloss.Style {
198+ t := styles.CurrentTheme()
199+ return t.S().Base.
200+ Width(m.width).
201+ Border(lipgloss.RoundedBorder()).
202+ BorderForeground(t.BorderFocus)
203+}
204+
205+// ID implements FilePicker.
206+func (m *model) ID() dialogs.DialogID {
207+ return FilePickerID
208+}
209+
210+// Position implements FilePicker.
211+func (m *model) Position() (int, int) {
212+ row := m.wHeight/4 - 2 // just a bit above the center
213+ col := m.wWidth / 2
214+ col -= m.width / 2
215+ return row, col
216+}
217+
218+func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
219+ fileInfo, err := os.Stat(filePath)
220+ if err != nil {
221+ return false, fmt.Errorf("error getting file info: %w", err)
222+ }
223+
224+ if fileInfo.Size() > sizeLimit {
225+ return true, nil
226+ }
227+
228+ return false, nil
229+}
internal/tui/components/dialogs/filepicker/keys.gonew file+69
@@ -0,0 +1,69 @@
1+package filepicker
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+// KeyMap defines keyboard bindings for dialog management.
9+type KeyMap struct {
10+ Select,
11+ Down,
12+ Up,
13+ Forward,
14+ Backward,
15+ Close key.Binding
16+}
17+
18+func DefaultKeyMap() KeyMap {
19+ return KeyMap{
20+ Select: key.NewBinding(
21+ key.WithKeys("enter"),
22+ key.WithHelp("enter", "accept"),
23+ ),
24+ Down: key.NewBinding(
25+ key.WithKeys("down", "j"),
26+ key.WithHelp("down/j", "move down"),
27+ ),
28+ Up: key.NewBinding(
29+ key.WithKeys("up", "k"),
30+ key.WithHelp("up/k", "move up"),
31+ ),
32+ Forward: key.NewBinding(
33+ key.WithKeys("right", "l"),
34+ key.WithHelp("right/l", "move forward"),
35+ ),
36+ Backward: key.NewBinding(
37+ key.WithKeys("left", "h"),
38+ key.WithHelp("left/h", "move backward"),
39+ ),
40+
41+ Close: key.NewBinding(
42+ key.WithKeys("esc"),
43+ key.WithHelp("esc", "close/exit"),
44+ ),
45+ }
46+}
47+
48+// FullHelp implements help.KeyMap.
49+func (k KeyMap) FullHelp() [][]key.Binding {
50+ m := [][]key.Binding{}
51+ slice := layout.KeyMapToSlice(k)
52+ for i := 0; i < len(slice); i += 4 {
53+ end := min(i+4, len(slice))
54+ m = append(m, slice[i:end])
55+ }
56+ return m
57+}
58+
59+// ShortHelp implements help.KeyMap.
60+func (k KeyMap) ShortHelp() []key.Binding {
61+ return []key.Binding{
62+ key.NewBinding(
63+ key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
64+ key.WithHelp("↑↓←→", "navigate"),
65+ ),
66+ k.Select,
67+ k.Close,
68+ }
69+}
internal/tui/components/dialogs/init/init.gonew file+213
@@ -0,0 +1,213 @@
1+package init
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/lipgloss/v2"
7+
8+ "github.com/charmbracelet/crush/internal/config"
9+ cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
10+ "github.com/charmbracelet/crush/internal/tui/components/core"
11+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
13+ "github.com/charmbracelet/crush/internal/tui/util"
14+)
15+
16+const InitDialogID dialogs.DialogID = "init"
17+
18+// InitDialogCmp is a component that asks the user if they want to initialize the project.
19+type InitDialogCmp interface {
20+ dialogs.DialogModel
21+}
22+
23+type initDialogCmp struct {
24+ wWidth, wHeight int
25+ width, height int
26+ selected int
27+ keyMap KeyMap
28+}
29+
30+// NewInitDialogCmp creates a new InitDialogCmp.
31+func NewInitDialogCmp() InitDialogCmp {
32+ return &initDialogCmp{
33+ selected: 0,
34+ keyMap: DefaultKeyMap(),
35+ }
36+}
37+
38+// Init implements tea.Model.
39+func (m *initDialogCmp) Init() tea.Cmd {
40+ return nil
41+}
42+
43+// Update implements tea.Model.
44+func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45+ switch msg := msg.(type) {
46+ case tea.WindowSizeMsg:
47+ m.wWidth = msg.Width
48+ m.wHeight = msg.Height
49+ cmd := m.SetSize()
50+ return m, cmd
51+ case tea.KeyPressMsg:
52+ switch {
53+ case key.Matches(msg, m.keyMap.Close):
54+ return m, tea.Batch(
55+ util.CmdHandler(dialogs.CloseDialogMsg{}),
56+ m.handleInitialization(false),
57+ )
58+ case key.Matches(msg, m.keyMap.ChangeSelection):
59+ m.selected = (m.selected + 1) % 2
60+ return m, nil
61+ case key.Matches(msg, m.keyMap.Select):
62+ return m, tea.Batch(
63+ util.CmdHandler(dialogs.CloseDialogMsg{}),
64+ m.handleInitialization(m.selected == 0),
65+ )
66+ case key.Matches(msg, m.keyMap.Y):
67+ return m, tea.Batch(
68+ util.CmdHandler(dialogs.CloseDialogMsg{}),
69+ m.handleInitialization(true),
70+ )
71+ case key.Matches(msg, m.keyMap.N):
72+ return m, tea.Batch(
73+ util.CmdHandler(dialogs.CloseDialogMsg{}),
74+ m.handleInitialization(false),
75+ )
76+ }
77+ }
78+ return m, nil
79+}
80+
81+func (m *initDialogCmp) renderButtons() string {
82+ t := styles.CurrentTheme()
83+ baseStyle := t.S().Base
84+
85+ buttons := []core.ButtonOpts{
86+ {
87+ Text: "Yes",
88+ UnderlineIndex: 0, // "Y"
89+ Selected: m.selected == 0,
90+ },
91+ {
92+ Text: "No",
93+ UnderlineIndex: 0, // "N"
94+ Selected: m.selected == 1,
95+ },
96+ }
97+
98+ content := core.SelectableButtons(buttons, " ")
99+
100+ return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content)
101+}
102+
103+func (m *initDialogCmp) renderContent() string {
104+ t := styles.CurrentTheme()
105+ baseStyle := t.S().Base
106+
107+ explanation := t.S().Text.
108+ Width(m.width - 4).
109+ 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.")
110+
111+ question := t.S().Text.
112+ Width(m.width - 4).
113+ Render("Would you like to initialize this project?")
114+
115+ return baseStyle.Render(lipgloss.JoinVertical(
116+ lipgloss.Left,
117+ explanation,
118+ "",
119+ question,
120+ ))
121+}
122+
123+func (m *initDialogCmp) render() string {
124+ t := styles.CurrentTheme()
125+ baseStyle := t.S().Base
126+ title := core.Title("Initialize Project", m.width-4)
127+
128+ content := m.renderContent()
129+ buttons := m.renderButtons()
130+
131+ dialogContent := lipgloss.JoinVertical(
132+ lipgloss.Top,
133+ title,
134+ "",
135+ content,
136+ "",
137+ buttons,
138+ "",
139+ )
140+
141+ return baseStyle.
142+ Padding(0, 1).
143+ Border(lipgloss.RoundedBorder()).
144+ BorderForeground(t.BorderFocus).
145+ Width(m.width).
146+ Render(dialogContent)
147+}
148+
149+// View implements tea.Model.
150+func (m *initDialogCmp) View() tea.View {
151+ return tea.NewView(m.render())
152+}
153+
154+// SetSize sets the size of the component.
155+func (m *initDialogCmp) SetSize() tea.Cmd {
156+ m.width = min(90, m.wWidth)
157+ m.height = min(15, m.wHeight)
158+ return nil
159+}
160+
161+// ID implements DialogModel.
162+func (m *initDialogCmp) ID() dialogs.DialogID {
163+ return InitDialogID
164+}
165+
166+// Position implements DialogModel.
167+func (m *initDialogCmp) Position() (int, int) {
168+ row := (m.wHeight / 2) - (m.height / 2)
169+ col := (m.wWidth / 2) - (m.width / 2)
170+ return row, col
171+}
172+
173+// handleInitialization handles the initialization logic when the dialog is closed.
174+func (m *initDialogCmp) handleInitialization(initialize bool) tea.Cmd {
175+ if initialize {
176+ // Run the initialization command
177+ prompt := `Please analyze this codebase and create a Crush.md file containing:
178+1. Build/lint/test commands - especially for running a single test
179+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
180+
181+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.
182+If there's already a crush.md, improve it.
183+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
184+
185+ // Mark the project as initialized
186+ if err := config.MarkProjectInitialized(); err != nil {
187+ return util.ReportError(err)
188+ }
189+
190+ return tea.Sequence(
191+ util.CmdHandler(cmpChat.SessionClearedMsg{}),
192+ util.CmdHandler(cmpChat.SendMsg{
193+ Text: prompt,
194+ }),
195+ )
196+ } else {
197+ // Mark the project as initialized without running the command
198+ if err := config.MarkProjectInitialized(); err != nil {
199+ return util.ReportError(err)
200+ }
201+ }
202+ return nil
203+}
204+
205+// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
206+type CloseInitDialogMsg struct {
207+ Initialize bool
208+}
209+
210+// ShowInitDialogMsg is a message that is sent to show the init dialog.
211+type ShowInitDialogMsg struct {
212+ Show bool
213+}
internal/tui/components/dialogs/init/keys.gonew file+59
@@ -0,0 +1,59 @@
1+package init
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ ChangeSelection,
10+ Select,
11+ Y,
12+ N,
13+ Close key.Binding
14+}
15+
16+func DefaultKeyMap() KeyMap {
17+ return KeyMap{
18+ ChangeSelection: key.NewBinding(
19+ key.WithKeys("tab", "left", "right", "h", "l"),
20+ key.WithHelp("tab/←/→", "toggle selection"),
21+ ),
22+ Select: key.NewBinding(
23+ key.WithKeys("enter"),
24+ key.WithHelp("enter", "confirm"),
25+ ),
26+ Y: key.NewBinding(
27+ key.WithKeys("y"),
28+ key.WithHelp("y", "yes"),
29+ ),
30+ N: key.NewBinding(
31+ key.WithKeys("n"),
32+ key.WithHelp("n", "no"),
33+ ),
34+ Close: key.NewBinding(
35+ key.WithKeys("esc"),
36+ key.WithHelp("esc", "cancel"),
37+ ),
38+ }
39+}
40+
41+// FullHelp implements help.KeyMap.
42+func (k KeyMap) FullHelp() [][]key.Binding {
43+ m := [][]key.Binding{}
44+ slice := layout.KeyMapToSlice(k)
45+ for i := 0; i < len(slice); i += 4 {
46+ end := min(i+4, len(slice))
47+ m = append(m, slice[i:end])
48+ }
49+ return m
50+}
51+
52+// ShortHelp implements help.KeyMap.
53+func (k KeyMap) ShortHelp() []key.Binding {
54+ return []key.Binding{
55+ k.ChangeSelection,
56+ k.Select,
57+ k.Close,
58+ }
59+}
internal/tui/components/dialogs/keys.gonew file+37
@@ -0,0 +1,37 @@
1+package dialogs
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+// KeyMap defines keyboard bindings for dialog management.
9+type KeyMap struct {
10+ Close key.Binding
11+}
12+
13+func DefaultKeyMap() KeyMap {
14+ return KeyMap{
15+ Close: key.NewBinding(
16+ key.WithKeys("esc"),
17+ ),
18+ }
19+}
20+
21+// FullHelp implements help.KeyMap.
22+func (k KeyMap) FullHelp() [][]key.Binding {
23+ m := [][]key.Binding{}
24+ slice := layout.KeyMapToSlice(k)
25+ for i := 0; i < len(slice); i += 4 {
26+ end := min(i+4, len(slice))
27+ m = append(m, slice[i:end])
28+ }
29+ return m
30+}
31+
32+// ShortHelp implements help.KeyMap.
33+func (k KeyMap) ShortHelp() []key.Binding {
34+ return []key.Binding{
35+ k.Close,
36+ }
37+}
internal/tui/components/dialogs/models/keys.gonew file+58
@@ -0,0 +1,58 @@
1+package models
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Select,
10+ Next,
11+ Previous,
12+ Close key.Binding
13+}
14+
15+func DefaultKeyMap() KeyMap {
16+ return KeyMap{
17+ Select: key.NewBinding(
18+ key.WithKeys("enter", "tab", "ctrl+y"),
19+ key.WithHelp("enter", "confirm"),
20+ ),
21+ Next: key.NewBinding(
22+ key.WithKeys("down", "ctrl+n"),
23+ key.WithHelp("↓", "next item"),
24+ ),
25+ Previous: key.NewBinding(
26+ key.WithKeys("up", "ctrl+p"),
27+ key.WithHelp("↑", "previous item"),
28+ ),
29+ Close: key.NewBinding(
30+ key.WithKeys("esc"),
31+ key.WithHelp("esc", "cancel"),
32+ ),
33+ }
34+}
35+
36+// FullHelp implements help.KeyMap.
37+func (k KeyMap) FullHelp() [][]key.Binding {
38+ m := [][]key.Binding{}
39+ slice := layout.KeyMapToSlice(k)
40+ for i := 0; i < len(slice); i += 4 {
41+ end := min(i+4, len(slice))
42+ m = append(m, slice[i:end])
43+ }
44+ return m
45+}
46+
47+// ShortHelp implements help.KeyMap.
48+func (k KeyMap) ShortHelp() []key.Binding {
49+ return []key.Binding{
50+ key.NewBinding(
51+
52+ key.WithKeys("down", "up"),
53+ key.WithHelp("↑↓", "choose"),
54+ ),
55+ k.Select,
56+ k.Close,
57+ }
58+}
internal/tui/components/dialogs/models/models.gonew file+263
@@ -0,0 +1,263 @@
1+package models
2+
3+import (
4+ "slices"
5+
6+ "github.com/charmbracelet/bubbles/v2/help"
7+ "github.com/charmbracelet/bubbles/v2/key"
8+ tea "github.com/charmbracelet/bubbletea/v2"
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/llm/models"
11+ "github.com/charmbracelet/crush/internal/tui/components/completions"
12+ "github.com/charmbracelet/crush/internal/tui/components/core"
13+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
14+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
16+ "github.com/charmbracelet/crush/internal/tui/styles"
17+ "github.com/charmbracelet/crush/internal/tui/util"
18+ "github.com/charmbracelet/lipgloss/v2"
19+)
20+
21+const (
22+ ModelsDialogID dialogs.DialogID = "models"
23+
24+ defaultWidth = 60
25+)
26+
27+// ModelSelectedMsg is sent when a model is selected
28+type ModelSelectedMsg struct {
29+ Model models.Model
30+}
31+
32+// CloseModelDialogMsg is sent when a model is selected
33+type CloseModelDialogMsg struct{}
34+
35+// ModelDialog interface for the model selection dialog
36+type ModelDialog interface {
37+ dialogs.DialogModel
38+}
39+
40+type modelDialogCmp struct {
41+ width int
42+ wWidth int // Width of the terminal window
43+ wHeight int // Height of the terminal window
44+
45+ modelList list.ListModel
46+ keyMap KeyMap
47+ help help.Model
48+}
49+
50+func NewModelDialogCmp() ModelDialog {
51+ listKeyMap := list.DefaultKeyMap()
52+ keyMap := DefaultKeyMap()
53+
54+ listKeyMap.Down.SetEnabled(false)
55+ listKeyMap.Up.SetEnabled(false)
56+ listKeyMap.NDown.SetEnabled(false)
57+ listKeyMap.NUp.SetEnabled(false)
58+ listKeyMap.HalfPageDown.SetEnabled(false)
59+ listKeyMap.HalfPageUp.SetEnabled(false)
60+ listKeyMap.Home.SetEnabled(false)
61+ listKeyMap.End.SetEnabled(false)
62+
63+ listKeyMap.DownOneItem = keyMap.Next
64+ listKeyMap.UpOneItem = keyMap.Previous
65+
66+ t := styles.CurrentTheme()
67+ inputStyle := t.S().Base.Padding(0, 1, 0, 1)
68+ modelList := list.New(
69+ list.WithFilterable(true),
70+ list.WithKeyMap(listKeyMap),
71+ list.WithInputStyle(inputStyle),
72+ list.WithWrapNavigation(true),
73+ )
74+ help := help.New()
75+ help.Styles = t.S().Help
76+
77+ return &modelDialogCmp{
78+ modelList: modelList,
79+ width: defaultWidth,
80+ keyMap: DefaultKeyMap(),
81+ help: help,
82+ }
83+}
84+
85+var ProviderPopularity = map[models.ModelProvider]int{
86+ models.ProviderAnthropic: 1,
87+ models.ProviderOpenAI: 2,
88+ models.ProviderGemini: 3,
89+ models.ProviderGROQ: 4,
90+ models.ProviderOpenRouter: 5,
91+ models.ProviderBedrock: 6,
92+ models.ProviderAzure: 7,
93+ models.ProviderVertexAI: 8,
94+ models.ProviderXAI: 9,
95+}
96+
97+var ProviderName = map[models.ModelProvider]string{
98+ models.ProviderAnthropic: "Anthropic",
99+ models.ProviderOpenAI: "OpenAI",
100+ models.ProviderGemini: "Gemini",
101+ models.ProviderGROQ: "Groq",
102+ models.ProviderOpenRouter: "OpenRouter",
103+ models.ProviderBedrock: "AWS Bedrock",
104+ models.ProviderAzure: "Azure",
105+ models.ProviderVertexAI: "VertexAI",
106+ models.ProviderXAI: "xAI",
107+}
108+
109+func (m *modelDialogCmp) Init() tea.Cmd {
110+ cfg := config.Get()
111+ enabledProviders := getEnabledProviders(cfg)
112+
113+ modelItems := []util.Model{}
114+ for _, provider := range enabledProviders {
115+ name, ok := ProviderName[provider]
116+ if !ok {
117+ name = string(provider) // Fallback to provider ID if name is not defined
118+ }
119+ modelItems = append(modelItems, commands.NewItemSection(name))
120+ for _, model := range getModelsForProvider(provider) {
121+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, model))
122+ }
123+ }
124+ m.modelList.SetItems(modelItems)
125+ return m.modelList.Init()
126+}
127+
128+func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
129+ switch msg := msg.(type) {
130+ case tea.WindowSizeMsg:
131+ m.wWidth = msg.Width
132+ m.wHeight = msg.Height
133+ return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
134+ case tea.KeyPressMsg:
135+ switch {
136+ case key.Matches(msg, m.keyMap.Select):
137+ selectedItemInx := m.modelList.SelectedIndex()
138+ if selectedItemInx == list.NoSelection {
139+ return m, nil // No item selected, do nothing
140+ }
141+ items := m.modelList.Items()
142+ selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(models.Model)
143+
144+ return m, tea.Sequence(
145+ util.CmdHandler(dialogs.CloseDialogMsg{}),
146+ util.CmdHandler(ModelSelectedMsg{Model: selectedItem}),
147+ )
148+ case key.Matches(msg, m.keyMap.Close):
149+ return m, util.CmdHandler(dialogs.CloseDialogMsg{})
150+ default:
151+ u, cmd := m.modelList.Update(msg)
152+ m.modelList = u.(list.ListModel)
153+ return m, cmd
154+ }
155+ }
156+ return m, nil
157+}
158+
159+func (m *modelDialogCmp) View() tea.View {
160+ t := styles.CurrentTheme()
161+ listView := m.modelList.View()
162+ content := lipgloss.JoinVertical(
163+ lipgloss.Left,
164+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-4)),
165+ listView.String(),
166+ "",
167+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
168+ )
169+ v := tea.NewView(m.style().Render(content))
170+ if listView.Cursor() != nil {
171+ c := m.moveCursor(listView.Cursor())
172+ v.SetCursor(c)
173+ }
174+ return v
175+}
176+
177+func (m *modelDialogCmp) style() lipgloss.Style {
178+ t := styles.CurrentTheme()
179+ return t.S().Base.
180+ Width(m.width).
181+ Border(lipgloss.RoundedBorder()).
182+ BorderForeground(t.BorderFocus)
183+}
184+
185+func (m *modelDialogCmp) listWidth() int {
186+ return defaultWidth - 2 // 4 for padding
187+}
188+
189+func (m *modelDialogCmp) listHeight() int {
190+ listHeigh := len(m.modelList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
191+ return min(listHeigh, m.wHeight/2)
192+}
193+
194+func GetSelectedModel(cfg *config.Config) models.Model {
195+ agentCfg := cfg.Agents[config.AgentCoder]
196+ selectedModelId := agentCfg.Model
197+ return models.SupportedModels[selectedModelId]
198+}
199+
200+func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
201+ var providers []models.ModelProvider
202+ for providerId, provider := range cfg.Providers {
203+ if !provider.Disabled {
204+ providers = append(providers, providerId)
205+ }
206+ }
207+
208+ // Sort by provider popularity
209+ slices.SortFunc(providers, func(a, b models.ModelProvider) int {
210+ rA := ProviderPopularity[a]
211+ rB := ProviderPopularity[b]
212+
213+ // models not included in popularity ranking default to last
214+ if rA == 0 {
215+ rA = 999
216+ }
217+ if rB == 0 {
218+ rB = 999
219+ }
220+ return rA - rB
221+ })
222+ return providers
223+}
224+
225+func getModelsForProvider(provider models.ModelProvider) []models.Model {
226+ var providerModels []models.Model
227+ for _, model := range models.SupportedModels {
228+ if model.Provider == provider {
229+ providerModels = append(providerModels, model)
230+ }
231+ }
232+
233+ // reverse alphabetical order (if llm naming was consistent latest would appear first)
234+ slices.SortFunc(providerModels, func(a, b models.Model) int {
235+ if a.Name > b.Name {
236+ return -1
237+ } else if a.Name < b.Name {
238+ return 1
239+ }
240+ return 0
241+ })
242+
243+ return providerModels
244+}
245+
246+func (m *modelDialogCmp) Position() (int, int) {
247+ row := m.wHeight/4 - 2 // just a bit above the center
248+ col := m.wWidth / 2
249+ col -= m.width / 2
250+ return row, col
251+}
252+
253+func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
254+ row, col := m.Position()
255+ offset := row + 3 // Border + title
256+ cursor.Y += offset
257+ cursor.X = cursor.X + col + 2
258+ return cursor
259+}
260+
261+func (m *modelDialogCmp) ID() dialogs.DialogID {
262+ return ModelsDialogID
263+}
internal/tui/components/dialogs/permissions/keys.gonew file+70
@@ -0,0 +1,70 @@
1+package permissions
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Left,
10+ Right,
11+ Tab,
12+ Select,
13+ Allow,
14+ AllowSession,
15+ Deny key.Binding
16+}
17+
18+func DefaultKeyMap() KeyMap {
19+ return KeyMap{
20+ Left: key.NewBinding(
21+ key.WithKeys("left", "h"),
22+ key.WithHelp("←", "previous"),
23+ ),
24+ Right: key.NewBinding(
25+ key.WithKeys("right", "l"),
26+ key.WithHelp("→", "next"),
27+ ),
28+ Tab: key.NewBinding(
29+ key.WithKeys("tab"),
30+ key.WithHelp("tab", "switch"),
31+ ),
32+ Allow: key.NewBinding(
33+ key.WithKeys("a", "ctrl+a"),
34+ key.WithHelp("a", "allow"),
35+ ),
36+ AllowSession: key.NewBinding(
37+ key.WithKeys("s", "ctrl+s"),
38+ key.WithHelp("s", "allow session"),
39+ ),
40+ Deny: key.NewBinding(
41+ key.WithKeys("d", "ctrl+d"),
42+ key.WithHelp("d", "deny"),
43+ ),
44+ Select: key.NewBinding(
45+ key.WithKeys("enter", "tab", "ctrl+y"),
46+ key.WithHelp("enter", "confirm"),
47+ ),
48+ }
49+}
50+
51+// FullHelp implements help.KeyMap.
52+func (k KeyMap) FullHelp() [][]key.Binding {
53+ m := [][]key.Binding{}
54+ slice := layout.KeyMapToSlice(k)
55+ for i := 0; i < len(slice); i += 4 {
56+ end := min(i+4, len(slice))
57+ m = append(m, slice[i:end])
58+ }
59+ return m
60+}
61+
62+// ShortHelp implements help.KeyMap.
63+func (k KeyMap) ShortHelp() []key.Binding {
64+ return []key.Binding{
65+ k.Allow,
66+ k.AllowSession,
67+ k.Deny,
68+ k.Select,
69+ }
70+}
internal/tui/components/dialogs/permissions/permissions.gonew file+473
@@ -0,0 +1,473 @@
1+package permissions
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/bubbles/v2/key"
8+ "github.com/charmbracelet/bubbles/v2/viewport"
9+ tea "github.com/charmbracelet/bubbletea/v2"
10+ "github.com/charmbracelet/crush/internal/diff"
11+ "github.com/charmbracelet/crush/internal/fileutil"
12+ "github.com/charmbracelet/crush/internal/llm/tools"
13+ "github.com/charmbracelet/crush/internal/permission"
14+ "github.com/charmbracelet/crush/internal/tui/components/core"
15+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
16+ "github.com/charmbracelet/crush/internal/tui/styles"
17+ "github.com/charmbracelet/crush/internal/tui/util"
18+ "github.com/charmbracelet/lipgloss/v2"
19+ "github.com/charmbracelet/x/ansi"
20+)
21+
22+type PermissionAction string
23+
24+// Permission responses
25+const (
26+ PermissionAllow PermissionAction = "allow"
27+ PermissionAllowForSession PermissionAction = "allow_session"
28+ PermissionDeny PermissionAction = "deny"
29+
30+ PermissionsDialogID dialogs.DialogID = "permissions"
31+)
32+
33+// PermissionResponseMsg represents the user's response to a permission request
34+type PermissionResponseMsg struct {
35+ Permission permission.PermissionRequest
36+ Action PermissionAction
37+}
38+
39+// PermissionDialogCmp interface for permission dialog component
40+type PermissionDialogCmp interface {
41+ dialogs.DialogModel
42+}
43+
44+// permissionDialogCmp is the implementation of PermissionDialog
45+type permissionDialogCmp struct {
46+ wWidth int
47+ wHeight int
48+ width int
49+ height int
50+ permission permission.PermissionRequest
51+ contentViewPort viewport.Model
52+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
53+
54+ keyMap KeyMap
55+}
56+
57+func NewPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialogCmp {
58+ // Create viewport for content
59+ contentViewport := viewport.New()
60+ return &permissionDialogCmp{
61+ contentViewPort: contentViewport,
62+ selectedOption: 0, // Default to "Allow"
63+ permission: permission,
64+ keyMap: DefaultKeyMap(),
65+ }
66+}
67+
68+func (p *permissionDialogCmp) Init() tea.Cmd {
69+ return p.contentViewPort.Init()
70+}
71+
72+func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73+ var cmds []tea.Cmd
74+
75+ switch msg := msg.(type) {
76+ case tea.WindowSizeMsg:
77+ p.wWidth = msg.Width
78+ p.wHeight = msg.Height
79+ cmd := p.SetSize()
80+ cmds = append(cmds, cmd)
81+ case tea.KeyPressMsg:
82+ switch {
83+ case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
84+ p.selectedOption = (p.selectedOption + 1) % 3
85+ return p, nil
86+ case key.Matches(msg, p.keyMap.Left):
87+ p.selectedOption = (p.selectedOption + 2) % 3
88+ case key.Matches(msg, p.keyMap.Select):
89+ return p, p.selectCurrentOption()
90+ case key.Matches(msg, p.keyMap.Allow):
91+ return p, tea.Batch(
92+ util.CmdHandler(dialogs.CloseDialogMsg{}),
93+ util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
94+ )
95+ case key.Matches(msg, p.keyMap.AllowSession):
96+ return p, tea.Batch(
97+ util.CmdHandler(dialogs.CloseDialogMsg{}),
98+ util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
99+ )
100+ case key.Matches(msg, p.keyMap.Deny):
101+ return p, tea.Batch(
102+ util.CmdHandler(dialogs.CloseDialogMsg{}),
103+ util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
104+ )
105+ default:
106+ // Pass other keys to viewport
107+ viewPort, cmd := p.contentViewPort.Update(msg)
108+ p.contentViewPort = viewPort
109+ cmds = append(cmds, cmd)
110+ }
111+ }
112+
113+ return p, tea.Batch(cmds...)
114+}
115+
116+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
117+ var action PermissionAction
118+
119+ switch p.selectedOption {
120+ case 0:
121+ action = PermissionAllow
122+ case 1:
123+ action = PermissionAllowForSession
124+ case 2:
125+ action = PermissionDeny
126+ }
127+
128+ return tea.Batch(
129+ util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
130+ util.CmdHandler(dialogs.CloseDialogMsg{}),
131+ )
132+}
133+
134+func (p *permissionDialogCmp) renderButtons() string {
135+ t := styles.CurrentTheme()
136+ baseStyle := t.S().Base
137+
138+ buttons := []core.ButtonOpts{
139+ {
140+ Text: "Allow",
141+ UnderlineIndex: 0, // "A"
142+ Selected: p.selectedOption == 0,
143+ },
144+ {
145+ Text: "Allow for Session",
146+ UnderlineIndex: 10, // "S" in "Session"
147+ Selected: p.selectedOption == 1,
148+ },
149+ {
150+ Text: "Deny",
151+ UnderlineIndex: 0, // "D"
152+ Selected: p.selectedOption == 2,
153+ },
154+ }
155+
156+ content := core.SelectableButtons(buttons, " ")
157+
158+ return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
159+}
160+
161+func (p *permissionDialogCmp) renderHeader() string {
162+ t := styles.CurrentTheme()
163+ baseStyle := t.S().Base
164+
165+ toolKey := t.S().Muted.Render("Tool")
166+ toolValue := t.S().Text.
167+ Width(p.width - lipgloss.Width(toolKey)).
168+ Render(fmt.Sprintf(" %s", p.permission.ToolName))
169+
170+ pathKey := t.S().Muted.Render("Path")
171+ pathValue := t.S().Text.
172+ Width(p.width - lipgloss.Width(pathKey)).
173+ Render(fmt.Sprintf(" %s", fileutil.PrettyPath(p.permission.Path)))
174+
175+ headerParts := []string{
176+ lipgloss.JoinHorizontal(
177+ lipgloss.Left,
178+ toolKey,
179+ toolValue,
180+ ),
181+ baseStyle.Render(strings.Repeat(" ", p.width)),
182+ lipgloss.JoinHorizontal(
183+ lipgloss.Left,
184+ pathKey,
185+ pathValue,
186+ ),
187+ baseStyle.Render(strings.Repeat(" ", p.width)),
188+ }
189+
190+ // Add tool-specific header information
191+ switch p.permission.ToolName {
192+ case tools.BashToolName:
193+ headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
194+ case tools.EditToolName:
195+ params := p.permission.Params.(tools.EditPermissionsParams)
196+ fileKey := t.S().Muted.Render("File")
197+ filePath := t.S().Text.
198+ Width(p.width - lipgloss.Width(fileKey)).
199+ Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath)))
200+ headerParts = append(headerParts,
201+ lipgloss.JoinHorizontal(
202+ lipgloss.Left,
203+ fileKey,
204+ filePath,
205+ ),
206+ baseStyle.Render(strings.Repeat(" ", p.width)),
207+ )
208+
209+ case tools.WriteToolName:
210+ params := p.permission.Params.(tools.WritePermissionsParams)
211+ fileKey := t.S().Muted.Render("File")
212+ filePath := t.S().Text.
213+ Width(p.width - lipgloss.Width(fileKey)).
214+ Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath)))
215+ headerParts = append(headerParts,
216+ lipgloss.JoinHorizontal(
217+ lipgloss.Left,
218+ fileKey,
219+ filePath,
220+ ),
221+ baseStyle.Render(strings.Repeat(" ", p.width)),
222+ )
223+ case tools.FetchToolName:
224+ headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
225+ }
226+
227+ return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
228+}
229+
230+func (p *permissionDialogCmp) renderBashContent() string {
231+ t := styles.CurrentTheme()
232+ baseStyle := t.S().Base.Background(t.BgSubtle)
233+ if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
234+ content := pr.Command
235+ t := styles.CurrentTheme()
236+ content = strings.TrimSpace(content)
237+ content = "\n" + content + "\n"
238+ lines := strings.Split(content, "\n")
239+
240+ width := p.width - 4
241+ var out []string
242+ for _, ln := range lines {
243+ ln = " " + ln // left padding
244+ if len(ln) > width {
245+ ln = ansi.Truncate(ln, width, "…")
246+ }
247+ out = append(out, t.S().Muted.
248+ Width(width).
249+ Foreground(t.FgBase).
250+ Background(t.BgSubtle).
251+ Render(ln))
252+ }
253+
254+ // Use the cache for markdown rendering
255+ renderedContent := strings.Join(out, "\n")
256+ finalContent := baseStyle.
257+ Width(p.contentViewPort.Width()).
258+ Render(renderedContent)
259+
260+ contentHeight := min(p.height-9, lipgloss.Height(finalContent))
261+ p.contentViewPort.SetHeight(contentHeight)
262+ p.contentViewPort.SetContent(finalContent)
263+ return p.styleViewport()
264+ }
265+ return ""
266+}
267+
268+func (p *permissionDialogCmp) renderEditContent() string {
269+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
270+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
271+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
272+ })
273+
274+ contentHeight := min(p.height-9, lipgloss.Height(diff))
275+ p.contentViewPort.SetHeight(contentHeight)
276+ p.contentViewPort.SetContent(diff)
277+ return p.styleViewport()
278+ }
279+ return ""
280+}
281+
282+func (p *permissionDialogCmp) renderPatchContent() string {
283+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
284+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
285+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
286+ })
287+
288+ contentHeight := min(p.height-9, lipgloss.Height(diff))
289+ p.contentViewPort.SetHeight(contentHeight)
290+ p.contentViewPort.SetContent(diff)
291+ return p.styleViewport()
292+ }
293+ return ""
294+}
295+
296+func (p *permissionDialogCmp) renderWriteContent() string {
297+ if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
298+ // Use the cache for diff rendering
299+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
300+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
301+ })
302+
303+ contentHeight := min(p.height-9, lipgloss.Height(diff))
304+ p.contentViewPort.SetHeight(contentHeight)
305+ p.contentViewPort.SetContent(diff)
306+ return p.styleViewport()
307+ }
308+ return ""
309+}
310+
311+func (p *permissionDialogCmp) renderFetchContent() string {
312+ t := styles.CurrentTheme()
313+ baseStyle := t.S().Base.Background(t.BgSubtle)
314+ if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
315+ content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
316+
317+ // Use the cache for markdown rendering
318+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
319+ r := styles.GetMarkdownRenderer(p.width - 4)
320+ s, err := r.Render(content)
321+ return s, err
322+ })
323+
324+ finalContent := baseStyle.
325+ Width(p.contentViewPort.Width()).
326+ Render(renderedContent)
327+
328+ contentHeight := min(p.height-9, lipgloss.Height(finalContent))
329+ p.contentViewPort.SetHeight(contentHeight)
330+ p.contentViewPort.SetContent(finalContent)
331+ return p.styleViewport()
332+ }
333+ return ""
334+}
335+
336+func (p *permissionDialogCmp) renderDefaultContent() string {
337+ t := styles.CurrentTheme()
338+ baseStyle := t.S().Base.Background(t.BgSubtle)
339+
340+ content := p.permission.Description
341+
342+ // Use the cache for markdown rendering
343+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
344+ r := styles.GetMarkdownRenderer(p.width - 4)
345+ s, err := r.Render(content)
346+ return s, err
347+ })
348+
349+ finalContent := baseStyle.
350+ Width(p.contentViewPort.Width()).
351+ Render(renderedContent)
352+ p.contentViewPort.SetContent(finalContent)
353+
354+ if renderedContent == "" {
355+ return ""
356+ }
357+
358+ return p.styleViewport()
359+}
360+
361+func (p *permissionDialogCmp) styleViewport() string {
362+ t := styles.CurrentTheme()
363+ return t.S().Base.Render(p.contentViewPort.View())
364+}
365+
366+func (p *permissionDialogCmp) render() string {
367+ t := styles.CurrentTheme()
368+ baseStyle := t.S().Base
369+ title := core.Title("Permission Required", p.width-4)
370+ // Render header
371+ headerContent := p.renderHeader()
372+ // Render buttons
373+ buttons := p.renderButtons()
374+
375+ p.contentViewPort.SetWidth(p.width - 4)
376+
377+ // Render content based on tool type
378+ var contentFinal string
379+ switch p.permission.ToolName {
380+ case tools.BashToolName:
381+ contentFinal = p.renderBashContent()
382+ case tools.EditToolName:
383+ contentFinal = p.renderEditContent()
384+ case tools.PatchToolName:
385+ contentFinal = p.renderPatchContent()
386+ case tools.WriteToolName:
387+ contentFinal = p.renderWriteContent()
388+ case tools.FetchToolName:
389+ contentFinal = p.renderFetchContent()
390+ default:
391+ contentFinal = p.renderDefaultContent()
392+ }
393+ // Calculate content height dynamically based on window size
394+
395+ content := lipgloss.JoinVertical(
396+ lipgloss.Top,
397+ title,
398+ "",
399+ headerContent,
400+ contentFinal,
401+ "",
402+ buttons,
403+ "",
404+ )
405+
406+ return baseStyle.
407+ Padding(0, 1).
408+ Border(lipgloss.RoundedBorder()).
409+ BorderForeground(t.BorderFocus).
410+ Width(p.width).
411+ Render(
412+ content,
413+ )
414+}
415+
416+func (p *permissionDialogCmp) View() tea.View {
417+ return tea.NewView(p.render())
418+}
419+
420+func (p *permissionDialogCmp) SetSize() tea.Cmd {
421+ if p.permission.ID == "" {
422+ return nil
423+ }
424+ switch p.permission.ToolName {
425+ case tools.BashToolName:
426+ p.width = int(float64(p.wWidth) * 0.4)
427+ p.height = int(float64(p.wHeight) * 0.3)
428+ case tools.EditToolName:
429+ p.width = int(float64(p.wWidth) * 0.8)
430+ p.height = int(float64(p.wHeight) * 0.8)
431+ case tools.WriteToolName:
432+ p.width = int(float64(p.wWidth) * 0.8)
433+ p.height = int(float64(p.wHeight) * 0.8)
434+ case tools.FetchToolName:
435+ p.width = int(float64(p.wWidth) * 0.4)
436+ p.height = int(float64(p.wHeight) * 0.3)
437+ default:
438+ p.width = int(float64(p.wWidth) * 0.7)
439+ p.height = int(float64(p.wHeight) * 0.5)
440+ }
441+ return nil
442+}
443+
444+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
445+ content, err := generator()
446+ if err != nil {
447+ return fmt.Sprintf("Error formatting diff: %v", err)
448+ }
449+ return content
450+}
451+
452+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
453+ content, err := generator()
454+ if err != nil {
455+ return fmt.Sprintf("Error rendering markdown: %v", err)
456+ }
457+
458+ return content
459+}
460+
461+// ID implements PermissionDialogCmp.
462+func (p *permissionDialogCmp) ID() dialogs.DialogID {
463+ return PermissionsDialogID
464+}
465+
466+// Position implements PermissionDialogCmp.
467+func (p *permissionDialogCmp) Position() (int, int) {
468+ row := (p.wHeight / 2) - 2 // Just a bit above the center
469+ row -= p.height / 2
470+ col := p.wWidth / 2
471+ col -= p.width / 2
472+ return row, col
473+}
internal/tui/components/dialogs/quit/keys.gonew file+64
@@ -0,0 +1,64 @@
1+package quit
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+// KeyMap defines the keyboard bindings for the quit dialog.
9+type KeyMap struct {
10+ LeftRight,
11+ EnterSpace,
12+ Yes,
13+ No,
14+ Tab,
15+ Close key.Binding
16+}
17+
18+func DefaultKeymap() KeyMap {
19+ return KeyMap{
20+ LeftRight: key.NewBinding(
21+ key.WithKeys("left", "right"),
22+ key.WithHelp("←/→", "switch options"),
23+ ),
24+ EnterSpace: key.NewBinding(
25+ key.WithKeys("enter", " "),
26+ key.WithHelp("enter/space", "confirm"),
27+ ),
28+ Yes: key.NewBinding(
29+ key.WithKeys("y", "Y", "ctrl+c"),
30+ key.WithHelp("y/Y/ctrl+c", "yes"),
31+ ),
32+ No: key.NewBinding(
33+ key.WithKeys("n", "N"),
34+ key.WithHelp("n/N", "no"),
35+ ),
36+ Tab: key.NewBinding(
37+ key.WithKeys("tab"),
38+ key.WithHelp("tab", "switch options"),
39+ ),
40+ Close: key.NewBinding(
41+ key.WithKeys("esc"),
42+ key.WithHelp("esc", "cancel"),
43+ ),
44+ }
45+}
46+
47+// FullHelp implements help.KeyMap.
48+func (k KeyMap) FullHelp() [][]key.Binding {
49+ m := [][]key.Binding{}
50+ slice := layout.KeyMapToSlice(k)
51+ for i := 0; i < len(slice); i += 4 {
52+ end := min(i+4, len(slice))
53+ m = append(m, slice[i:end])
54+ }
55+ return m
56+}
57+
58+// ShortHelp implements help.KeyMap.
59+func (k KeyMap) ShortHelp() []key.Binding {
60+ return []key.Binding{
61+ k.LeftRight,
62+ k.EnterSpace,
63+ }
64+}
internal/tui/components/dialogs/quit/quit.gonew file+125
@@ -0,0 +1,125 @@
1+package quit
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
7+ "github.com/charmbracelet/crush/internal/tui/layout"
8+ "github.com/charmbracelet/crush/internal/tui/styles"
9+ "github.com/charmbracelet/crush/internal/tui/util"
10+ "github.com/charmbracelet/lipgloss/v2"
11+)
12+
13+const (
14+ question = "Are you sure you want to quit?"
15+ QuitDialogID dialogs.DialogID = "quit"
16+)
17+
18+// QuitDialog represents a confirmation dialog for quitting the application.
19+type QuitDialog interface {
20+ dialogs.DialogModel
21+ layout.Bindings
22+}
23+
24+type quitDialogCmp struct {
25+ wWidth int
26+ wHeight int
27+
28+ selectedNo bool // true if "No" button is selected
29+ keymap KeyMap
30+}
31+
32+// NewQuitDialog creates a new quit confirmation dialog.
33+func NewQuitDialog() QuitDialog {
34+ return &quitDialogCmp{
35+ selectedNo: true, // Default to "No" for safety
36+ keymap: DefaultKeymap(),
37+ }
38+}
39+
40+func (q *quitDialogCmp) Init() tea.Cmd {
41+ return nil
42+}
43+
44+// Update handles keyboard input for the quit dialog.
45+func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
46+ switch msg := msg.(type) {
47+ case tea.WindowSizeMsg:
48+ q.wWidth = msg.Width
49+ q.wHeight = msg.Height
50+ case tea.KeyPressMsg:
51+ switch {
52+ case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab):
53+ q.selectedNo = !q.selectedNo
54+ return q, nil
55+ case key.Matches(msg, q.keymap.EnterSpace):
56+ if !q.selectedNo {
57+ return q, tea.Quit
58+ }
59+ return q, util.CmdHandler(dialogs.CloseDialogMsg{})
60+ case key.Matches(msg, q.keymap.Yes):
61+ return q, tea.Quit
62+ case key.Matches(msg, q.keymap.No, q.keymap.Close):
63+ return q, util.CmdHandler(dialogs.CloseDialogMsg{})
64+ }
65+ }
66+ return q, nil
67+}
68+
69+// View renders the quit dialog with Yes/No buttons.
70+func (q *quitDialogCmp) View() tea.View {
71+ t := styles.CurrentTheme()
72+ baseStyle := t.S().Base
73+ yesStyle := t.S().Text
74+ noStyle := yesStyle
75+
76+ if q.selectedNo {
77+ noStyle = noStyle.Foreground(t.White).Background(t.Secondary)
78+ yesStyle = yesStyle.Background(t.BgSubtle)
79+ } else {
80+ yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary)
81+ noStyle = noStyle.Background(t.BgSubtle)
82+ }
83+
84+ yesButton := yesStyle.Padding(0, 1).Render("Yep!")
85+ noButton := noStyle.Padding(0, 1).Render("Nope")
86+
87+ buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
88+ lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton),
89+ )
90+
91+ content := baseStyle.Render(
92+ lipgloss.JoinVertical(
93+ lipgloss.Center,
94+ question,
95+ "",
96+ buttons,
97+ ),
98+ )
99+
100+ quitDialogStyle := baseStyle.
101+ Padding(1, 2).
102+ Border(lipgloss.RoundedBorder()).
103+ BorderForeground(t.BorderFocus)
104+
105+ return tea.NewView(
106+ quitDialogStyle.Render(content),
107+ )
108+}
109+
110+func (q *quitDialogCmp) BindingKeys() []key.Binding {
111+ return layout.KeyMapToSlice(q.keymap)
112+}
113+
114+func (q *quitDialogCmp) Position() (int, int) {
115+ row := q.wHeight / 2
116+ row -= 7 / 2
117+ col := q.wWidth / 2
118+ col -= (lipgloss.Width(question) + 4) / 2
119+
120+ return row, col
121+}
122+
123+func (q *quitDialogCmp) ID() dialogs.DialogID {
124+ return QuitDialogID
125+}
internal/tui/components/dialogs/sessions/keys.gonew file+58
@@ -0,0 +1,58 @@
1+package sessions
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Select,
10+ Next,
11+ Previous,
12+ Close key.Binding
13+}
14+
15+func DefaultKeyMap() KeyMap {
16+ return KeyMap{
17+ Select: key.NewBinding(
18+ key.WithKeys("enter", "tab", "ctrl+y"),
19+ key.WithHelp("enter", "confirm"),
20+ ),
21+ Next: key.NewBinding(
22+ key.WithKeys("down", "ctrl+n"),
23+ key.WithHelp("↓", "next item"),
24+ ),
25+ Previous: key.NewBinding(
26+ key.WithKeys("up", "ctrl+p"),
27+ key.WithHelp("↑", "previous item"),
28+ ),
29+ Close: key.NewBinding(
30+ key.WithKeys("esc"),
31+ key.WithHelp("esc", "cancel"),
32+ ),
33+ }
34+}
35+
36+// FullHelp implements help.KeyMap.
37+func (k KeyMap) FullHelp() [][]key.Binding {
38+ m := [][]key.Binding{}
39+ slice := layout.KeyMapToSlice(k)
40+ for i := 0; i < len(slice); i += 4 {
41+ end := min(i+4, len(slice))
42+ m = append(m, slice[i:end])
43+ }
44+ return m
45+}
46+
47+// ShortHelp implements help.KeyMap.
48+func (k KeyMap) ShortHelp() []key.Binding {
49+ return []key.Binding{
50+ key.NewBinding(
51+
52+ key.WithKeys("down", "up"),
53+ key.WithHelp("↑↓", "choose"),
54+ ),
55+ k.Select,
56+ k.Close,
57+ }
58+}
internal/tui/components/dialogs/sessions/sessions.gonew file+180
@@ -0,0 +1,180 @@
1+package sessions
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/help"
5+ "github.com/charmbracelet/bubbles/v2/key"
6+ tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/session"
8+ "github.com/charmbracelet/crush/internal/tui/components/chat"
9+ "github.com/charmbracelet/crush/internal/tui/components/completions"
10+ "github.com/charmbracelet/crush/internal/tui/components/core"
11+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
12+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
13+ "github.com/charmbracelet/crush/internal/tui/styles"
14+ "github.com/charmbracelet/crush/internal/tui/util"
15+ "github.com/charmbracelet/lipgloss/v2"
16+)
17+
18+const SessionsDialogID dialogs.DialogID = "sessions"
19+
20+// SessionDialog interface for the session switching dialog
21+type SessionDialog interface {
22+ dialogs.DialogModel
23+}
24+
25+type sessionDialogCmp struct {
26+ selectedInx int
27+ wWidth int
28+ wHeight int
29+ width int
30+ selectedSessionID string
31+ keyMap KeyMap
32+ sessionsList list.ListModel
33+ renderedSelected bool
34+ help help.Model
35+}
36+
37+// NewSessionDialogCmp creates a new session switching dialog
38+func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog {
39+ t := styles.CurrentTheme()
40+ listKeyMap := list.DefaultKeyMap()
41+ keyMap := DefaultKeyMap()
42+
43+ listKeyMap.Down.SetEnabled(false)
44+ listKeyMap.Up.SetEnabled(false)
45+ listKeyMap.NDown.SetEnabled(false)
46+ listKeyMap.NUp.SetEnabled(false)
47+ listKeyMap.HalfPageDown.SetEnabled(false)
48+ listKeyMap.HalfPageUp.SetEnabled(false)
49+ listKeyMap.Home.SetEnabled(false)
50+ listKeyMap.End.SetEnabled(false)
51+
52+ listKeyMap.DownOneItem = keyMap.Next
53+ listKeyMap.UpOneItem = keyMap.Previous
54+
55+ selectedInx := 0
56+ items := make([]util.Model, len(sessions))
57+ if len(sessions) > 0 {
58+ for i, session := range sessions {
59+ items[i] = completions.NewCompletionItem(session.Title, session)
60+ if session.ID == selectedID {
61+ selectedInx = i
62+ }
63+ }
64+ }
65+
66+ sessionsList := list.New(
67+ list.WithFilterable(true),
68+ list.WithFilterPlaceholder("Enter a session name"),
69+ list.WithKeyMap(listKeyMap),
70+ list.WithItems(items),
71+ list.WithWrapNavigation(true),
72+ )
73+ help := help.New()
74+ help.Styles = t.S().Help
75+ s := &sessionDialogCmp{
76+ selectedInx: selectedInx,
77+ selectedSessionID: selectedID,
78+ keyMap: DefaultKeyMap(),
79+ sessionsList: sessionsList,
80+ help: help,
81+ }
82+
83+ return s
84+}
85+
86+func (s *sessionDialogCmp) Init() tea.Cmd {
87+ return s.sessionsList.Init()
88+}
89+
90+func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91+ switch msg := msg.(type) {
92+ case tea.WindowSizeMsg:
93+ s.wWidth = msg.Width
94+ s.wHeight = msg.Height
95+ s.width = s.wWidth / 2
96+ var cmds []tea.Cmd
97+ cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
98+ if !s.renderedSelected {
99+ cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
100+ s.renderedSelected = true
101+ }
102+ return s, tea.Sequence(cmds...)
103+ case tea.KeyPressMsg:
104+ switch {
105+ case key.Matches(msg, s.keyMap.Select):
106+ if len(s.sessionsList.Items()) > 0 {
107+ items := s.sessionsList.Items()
108+ selectedItemInx := s.sessionsList.SelectedIndex()
109+ return s, tea.Sequence(
110+ util.CmdHandler(dialogs.CloseDialogMsg{}),
111+ util.CmdHandler(
112+ chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)),
113+ ),
114+ )
115+ }
116+ case key.Matches(msg, s.keyMap.Close):
117+ return s, util.CmdHandler(dialogs.CloseDialogMsg{})
118+ default:
119+ u, cmd := s.sessionsList.Update(msg)
120+ s.sessionsList = u.(list.ListModel)
121+ return s, cmd
122+ }
123+ }
124+ return s, nil
125+}
126+
127+func (s *sessionDialogCmp) View() tea.View {
128+ t := styles.CurrentTheme()
129+ listView := s.sessionsList.View()
130+ content := lipgloss.JoinVertical(
131+ lipgloss.Left,
132+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)),
133+ listView.String(),
134+ "",
135+ t.S().Base.Width(s.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(s.help.View(s.keyMap)),
136+ )
137+
138+ v := tea.NewView(s.style().Render(content))
139+ if listView.Cursor() != nil {
140+ c := s.moveCursor(listView.Cursor())
141+ v.SetCursor(c)
142+ }
143+ return v
144+}
145+
146+func (s *sessionDialogCmp) style() lipgloss.Style {
147+ t := styles.CurrentTheme()
148+ return t.S().Base.
149+ Width(s.width).
150+ Border(lipgloss.RoundedBorder()).
151+ BorderForeground(t.BorderFocus)
152+}
153+
154+func (s *sessionDialogCmp) listHeight() int {
155+ return s.wHeight/2 - 6 // 5 for the border, title and help
156+}
157+
158+func (s *sessionDialogCmp) listWidth() int {
159+ return s.width - 2 // 2 for the border
160+}
161+
162+func (s *sessionDialogCmp) Position() (int, int) {
163+ row := s.wHeight/4 - 2 // just a bit above the center
164+ col := s.wWidth / 2
165+ col -= s.width / 2
166+ return row, col
167+}
168+
169+func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
170+ row, col := s.Position()
171+ offset := row + 3 // Border + title
172+ cursor.Y += offset
173+ cursor.X = cursor.X + col + 2
174+ return cursor
175+}
176+
177+// ID implements SessionDialog.
178+func (s *sessionDialogCmp) ID() dialogs.DialogID {
179+ return SessionsDialogID
180+}
internal/tui/components/image/image.gonew file+86
@@ -0,0 +1,86 @@
1+// Based on the implementation by @trashhalo at:
2+// https://github.com/trashhalo/imgcat
3+package image
4+
5+import (
6+ "fmt"
7+ _ "image/jpeg"
8+ _ "image/png"
9+
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+)
12+
13+type Model struct {
14+ url string
15+ image string
16+ width uint
17+ height uint
18+ err error
19+}
20+
21+func New(width, height uint, url string) Model {
22+ return Model{
23+ width: width,
24+ height: height,
25+ url: url,
26+ }
27+}
28+
29+func (m Model) Init() tea.Cmd {
30+ return nil
31+}
32+
33+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
34+ switch msg := msg.(type) {
35+ case errMsg:
36+ m.err = msg
37+ return m, nil
38+ case redrawMsg:
39+ m.width = msg.width
40+ m.height = msg.height
41+ m.url = msg.url
42+ return m, loadURL(m.url)
43+ case loadMsg:
44+ return handleLoadMsg(m, msg)
45+ }
46+ return m, nil
47+}
48+
49+func (m Model) View() string {
50+ if m.err != nil {
51+ return fmt.Sprintf("couldn't load image(s): %v", m.err)
52+ }
53+ return m.image
54+}
55+
56+type errMsg struct{ error }
57+
58+func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
59+ return func() tea.Msg {
60+ return redrawMsg{
61+ width: width,
62+ height: height,
63+ url: url,
64+ }
65+ }
66+}
67+
68+func (m Model) UpdateURL(url string) tea.Cmd {
69+ return func() tea.Msg {
70+ return redrawMsg{
71+ width: m.width,
72+ height: m.height,
73+ url: url,
74+ }
75+ }
76+}
77+
78+type redrawMsg struct {
79+ width uint
80+ height uint
81+ url string
82+}
83+
84+func (m Model) IsLoading() bool {
85+ return m.image == ""
86+}
internal/tui/components/image/load.gonew file+146
@@ -0,0 +1,146 @@
1+// Based on the implementation by @trashhalo at:
2+// https://github.com/trashhalo/imgcat
3+package image
4+
5+import (
6+ "context"
7+ "image"
8+ "image/png"
9+ "io"
10+ "net/http"
11+ "os"
12+ "strings"
13+
14+ tea "github.com/charmbracelet/bubbletea/v2"
15+ "github.com/disintegration/imageorient"
16+ "github.com/lucasb-eyer/go-colorful"
17+ "github.com/muesli/termenv"
18+ "github.com/nfnt/resize"
19+ "github.com/srwiley/oksvg"
20+ "github.com/srwiley/rasterx"
21+)
22+
23+type loadMsg struct {
24+ io.ReadCloser
25+}
26+
27+func loadURL(url string) tea.Cmd {
28+ var r io.ReadCloser
29+ var err error
30+
31+ if strings.HasPrefix(url, "http") {
32+ var resp *http.Request
33+ resp, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
34+ r = resp.Body
35+ } else {
36+ r, err = os.Open(url)
37+ }
38+
39+ if err != nil {
40+ return func() tea.Msg {
41+ return errMsg{err}
42+ }
43+ }
44+
45+ return load(r)
46+}
47+
48+func load(r io.ReadCloser) tea.Cmd {
49+ return func() tea.Msg {
50+ return loadMsg{r}
51+ }
52+}
53+
54+func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
55+ defer msg.Close()
56+
57+ img, err := readerToImage(m.width, m.height, m.url, msg)
58+ if err != nil {
59+ return m, func() tea.Msg { return errMsg{err} }
60+ }
61+ m.image = img
62+ return m, nil
63+}
64+
65+func imageToString(width, height uint, img image.Image) (string, error) {
66+ img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
67+ b := img.Bounds()
68+ w := b.Max.X
69+ h := b.Max.Y
70+ p := termenv.ColorProfile()
71+ str := strings.Builder{}
72+ for y := 0; y < h; y += 2 {
73+ for x := w; x < int(width); x = x + 2 {
74+ str.WriteString(" ")
75+ }
76+ for x := range w {
77+ c1, _ := colorful.MakeColor(img.At(x, y))
78+ color1 := p.Color(c1.Hex())
79+ c2, _ := colorful.MakeColor(img.At(x, y+1))
80+ color2 := p.Color(c2.Hex())
81+ str.WriteString(termenv.String("▀").
82+ Foreground(color1).
83+ Background(color2).
84+ String())
85+ }
86+ str.WriteString("\n")
87+ }
88+ return str.String(), nil
89+}
90+
91+func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) {
92+ if strings.HasSuffix(strings.ToLower(url), ".svg") {
93+ return svgToImage(width, height, r)
94+ }
95+
96+ img, _, err := imageorient.Decode(r)
97+ if err != nil {
98+ return "", err
99+ }
100+
101+ return imageToString(width, height, img)
102+}
103+
104+func svgToImage(width uint, height uint, r io.Reader) (string, error) {
105+ // Original author: https://stackoverflow.com/users/10826783/usual-human
106+ // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
107+ // Adapted to use size from SVG, and to use temp file.
108+
109+ tmpPngFile, err := os.CreateTemp("", "img.*.png")
110+ if err != nil {
111+ return "", err
112+ }
113+ tmpPngPath := tmpPngFile.Name()
114+ defer os.Remove(tmpPngPath)
115+ defer tmpPngFile.Close()
116+
117+ // Rasterize the SVG:
118+ icon, err := oksvg.ReadIconStream(r)
119+ if err != nil {
120+ return "", err
121+ }
122+ w := int(icon.ViewBox.W)
123+ h := int(icon.ViewBox.H)
124+ icon.SetTarget(0, 0, float64(w), float64(h))
125+ rgba := image.NewRGBA(image.Rect(0, 0, w, h))
126+ icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1)
127+ // Write rasterized image as PNG:
128+ err = png.Encode(tmpPngFile, rgba)
129+ if err != nil {
130+ tmpPngFile.Close()
131+ return "", err
132+ }
133+ tmpPngFile.Close()
134+
135+ rPng, err := os.Open(tmpPngPath)
136+ if err != nil {
137+ return "", err
138+ }
139+ defer rPng.Close()
140+
141+ img, _, err := imageorient.Decode(rPng)
142+ if err != nil {
143+ return "", err
144+ }
145+ return imageToString(width, height, img)
146+}
internal/tui/components/logo/logo.gonew file+313
@@ -0,0 +1,313 @@
1+package logo
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "math/rand/v2"
7+ "strings"
8+
9+ "github.com/MakeNowJust/heredoc"
10+ "github.com/charmbracelet/crush/internal/tui/styles"
11+ "github.com/charmbracelet/lipgloss/v2"
12+ "github.com/charmbracelet/x/ansi"
13+ "github.com/charmbracelet/x/exp/slice"
14+)
15+
16+// letterform represents a letterform. It can be stretched horizontally by
17+// a given amount via the boolean argument.
18+type letterform func(bool) string
19+
20+const diag = `╱`
21+
22+// Opts are the options for rendering the Crush title art.
23+type Opts struct {
24+ FieldColor color.Color // diagonal lines
25+ TitleColorA color.Color // left gradient ramp point
26+ TitleColorB color.Color // right gradient ramp point
27+ CharmColor color.Color // Charm™ text color
28+ VersionColor color.Color // Version text color
29+}
30+
31+// Render renders the Crush logo. Set the argument to true to render the narrow
32+// version, intended for use in a sidebar.
33+//
34+// The compact argument determines whether it renders compact for the sidebar
35+// or wider for the main pane.
36+func Render(version string, compact bool, o Opts) string {
37+ const charm = " Charm™"
38+
39+ fg := func(c color.Color, s string) string {
40+ return lipgloss.NewStyle().Foreground(c).Render(s)
41+ }
42+
43+ // Title.
44+ crush := renderWord(1, !compact, letterC, letterR, letterU, letterS, letterH)
45+ crushWidth := lipgloss.Width(crush)
46+ b := new(strings.Builder)
47+ for r := range strings.SplitSeq(crush, "\n") {
48+ fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
49+ }
50+ crush = b.String()
51+
52+ // Charm and version.
53+ metaRowGap := 1
54+ maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
55+ version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
56+ gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
57+ metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
58+
59+ // Join the meta row and big Crush title.
60+ crush = strings.TrimSpace(metaRow + "\n" + crush)
61+
62+ // Narrow version.
63+ if compact {
64+ field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
65+ return strings.Join([]string{field, field, crush, field, ""}, "\n")
66+ }
67+
68+ fieldHeight := lipgloss.Height(crush)
69+
70+ // Left field.
71+ const leftWidth = 6
72+ leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
73+ leftField := new(strings.Builder)
74+ for range fieldHeight {
75+ fmt.Fprintln(leftField, leftFieldRow)
76+ }
77+
78+ // Right field.
79+ const rightWidth = 15
80+ const stepDownAt = 0
81+ rightField := new(strings.Builder)
82+ for i := range fieldHeight {
83+ width := rightWidth
84+ if i >= stepDownAt {
85+ width = rightWidth - (i - stepDownAt)
86+ }
87+ fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
88+ }
89+
90+ // Return the wide version.
91+ const hGap = " "
92+ return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
93+}
94+
95+// renderWord renders letterforms to fork a word.
96+func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string {
97+ if spacing < 0 {
98+ spacing = 0
99+ }
100+
101+ renderedLetterforms := make([]string, len(letterforms))
102+
103+ // pick one letter randomly to stretch
104+ stretchIndex := -1
105+ if stretchRandomLetter {
106+ stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec
107+ }
108+
109+ for i, letter := range letterforms {
110+ renderedLetterforms[i] = letter(i == stretchIndex)
111+ }
112+
113+ if spacing > 0 {
114+ // Add spaces between the letters and render.
115+ renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
116+ }
117+ return strings.TrimSpace(
118+ lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
119+ )
120+}
121+
122+// letterC renders the letter C in a stylized way. It takes an integer that
123+// determines how many cells to stretch the letter. If the stretch is less than
124+// 1, it defaults to no stretching.
125+func letterC(stretch bool) string {
126+ // Here's what we're making:
127+ //
128+ // ▄▀▀▀▀
129+ // █
130+ // ▀▀▀▀
131+
132+ left := heredoc.Doc(`
133+ ▄
134+ █
135+ `)
136+ right := heredoc.Doc(`
137+ ▀
138+
139+ ▀
140+ `)
141+ return joinLetterform(
142+ left,
143+ stretchLetterformPart(right, letterformProps{
144+ stretch: stretch,
145+ width: 4,
146+ minStretch: 7,
147+ maxStretch: 12,
148+ }),
149+ )
150+}
151+
152+// letterH renders the letter H in a stylized way. It takes an integer that
153+// determines how many cells to stretch the letter. If the stretch is less than
154+// 1, it defaults to no stretching.
155+func letterH(stretch bool) string {
156+ // Here's what we're making:
157+ //
158+ // █ █
159+ // █▀▀▀█
160+ // ▀ ▀
161+
162+ side := heredoc.Doc(`
163+ █
164+ █
165+ ▀`)
166+ middle := heredoc.Doc(`
167+
168+ ▀
169+ `)
170+ return joinLetterform(
171+ side,
172+ stretchLetterformPart(middle, letterformProps{
173+ stretch: stretch,
174+ width: 3,
175+ minStretch: 8,
176+ maxStretch: 12,
177+ }),
178+ side,
179+ )
180+}
181+
182+// letterR renders the letter R in a stylized way. It takes an integer that
183+// determines how many cells to stretch the letter. If the stretch is less than
184+// 1, it defaults to no stretching.
185+func letterR(stretch bool) string {
186+ // Here's what we're making:
187+ //
188+ // █▀▀▀▄
189+ // █▀▀▀▄
190+ // ▀ ▀
191+
192+ left := heredoc.Doc(`
193+ █
194+ █
195+ ▀
196+ `)
197+ center := heredoc.Doc(`
198+ ▀
199+ ▀
200+ `)
201+ right := heredoc.Doc(`
202+ ▄
203+ ▄
204+ ▀
205+ `)
206+ return joinLetterform(
207+ left,
208+ stretchLetterformPart(center, letterformProps{
209+ stretch: stretch,
210+ width: 3,
211+ minStretch: 7,
212+ maxStretch: 12,
213+ }),
214+ right,
215+ )
216+}
217+
218+// letterS renders the letter S in a stylized way. It takes an integer that
219+// determines how many cells to stretch the letter. If the stretch is less than
220+// 1, it defaults to no stretching.
221+func letterS(stretch bool) string {
222+ // Here's what we're making:
223+ //
224+ // ▄▀▀▀▀
225+ // ▀▀▀▄
226+ // ▀▀▀▀
227+
228+ left := heredoc.Doc(`
229+ ▄
230+
231+ ▀
232+ `)
233+ center := heredoc.Doc(`
234+ ▀
235+ ▀
236+ ▀
237+ `)
238+ right := heredoc.Doc(`
239+ ▀
240+ ▄
241+ `)
242+ return joinLetterform(
243+ left,
244+ stretchLetterformPart(center, letterformProps{
245+ stretch: stretch,
246+ width: 3,
247+ minStretch: 7,
248+ maxStretch: 12,
249+ }),
250+ right,
251+ )
252+}
253+
254+// letterU renders the letter U in a stylized way. It takes an integer that
255+// determines how many cells to stretch the letter. If the stretch is less than
256+// 1, it defaults to no stretching.
257+func letterU(stretch bool) string {
258+ // Here's what we're making:
259+ //
260+ // █ █
261+ // █ █
262+ // ▀▀▀
263+
264+ side := heredoc.Doc(`
265+ █
266+ █
267+ `)
268+ middle := heredoc.Doc(`
269+
270+
271+ ▀
272+ `)
273+ return joinLetterform(
274+ side,
275+ stretchLetterformPart(middle, letterformProps{
276+ stretch: stretch,
277+ width: 3,
278+ minStretch: 7,
279+ maxStretch: 12,
280+ }),
281+ side,
282+ )
283+}
284+
285+func joinLetterform(letters ...string) string {
286+ return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
287+}
288+
289+// letterformProps defines letterform stretching properties.
290+// for readability.
291+type letterformProps struct {
292+ width int
293+ minStretch int
294+ maxStretch int
295+ stretch bool
296+}
297+
298+// stretchLetterformPart is a helper function for letter stretching. If randomize
299+// is false the minimum number will be used.
300+func stretchLetterformPart(s string, p letterformProps) string {
301+ if p.maxStretch < p.minStretch {
302+ p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
303+ }
304+ n := p.width
305+ if p.stretch {
306+ n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
307+ }
308+ parts := make([]string, n)
309+ for i := range parts {
310+ parts[i] = s
311+ }
312+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
313+}
internal/tui/components/logs/details.go+82−42
@@ -5,18 +5,18 @@ import (
55 "strings"
66 "time"
77
8− "github.com/charmbracelet/bubbles/key"
9− "github.com/charmbracelet/bubbles/viewport"
10− tea "github.com/charmbracelet/bubbletea"
11− "github.com/charmbracelet/lipgloss"
12− "github.com/opencode-ai/opencode/internal/logging"
13− "github.com/opencode-ai/opencode/internal/tui/layout"
14− "github.com/opencode-ai/opencode/internal/tui/styles"
15− "github.com/opencode-ai/opencode/internal/tui/theme"
8+ "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/viewport"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/logging"
12+ "github.com/charmbracelet/crush/internal/tui/layout"
13+ "github.com/charmbracelet/crush/internal/tui/styles"
14+ "github.com/charmbracelet/crush/internal/tui/util"
15+ "github.com/charmbracelet/lipgloss/v2"
1616)
1717
1818type DetailComponent interface {
19− tea.Model
19+ util.Model
2020 layout.Sizeable
2121 layout.Bindings
2222}
@@ -50,45 +50,57 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5050
5151func (i *detailCmp) updateContent() {
5252 var content strings.Builder
53− t := theme.CurrentTheme()
53+ t := styles.CurrentTheme()
5454
55− // Format the header with timestamp and level
56− timeStyle := lipgloss.NewStyle().Foreground(t.TextMuted())
55+ if i.currentLog.ID == "" {
56+ content.WriteString(t.S().Muted.Render("No log selected"))
57+ i.viewport.SetContent(content.String())
58+ return
59+ }
60+
61+ // Level badge with background color
5762 levelStyle := getLevelStyle(i.currentLog.Level)
63+ levelBadge := levelStyle.Padding(0, 1).Render(strings.ToUpper(i.currentLog.Level))
64+
65+ // Timestamp with relative time
66+ timeStr := i.currentLog.Time.Format("2006-01-05 15:04:05 UTC")
67+ relativeTime := getRelativeTime(i.currentLog.Time)
68+ timeStyle := t.S().Muted
5869
70+ // Header line
5971 header := lipgloss.JoinHorizontal(
60− lipgloss.Center,
61− timeStyle.Render(i.currentLog.Time.Format(time.RFC3339)),
62− " ",
63− levelStyle.Render(i.currentLog.Level),
72+ lipgloss.Left,
73+ timeStr,
74+ " ",
75+ timeStyle.Render(relativeTime),
6476 )
6577
66− content.WriteString(lipgloss.NewStyle().Bold(true).Render(header))
78+ content.WriteString(levelBadge)
79+ content.WriteString("\n\n")
80+ content.WriteString(header)
6781 content.WriteString("\n\n")
6882
69− // Message with styling
70− messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
71− content.WriteString(messageStyle.Render("Message:"))
83+ // Message section
84+ messageHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true)
85+ content.WriteString(messageHeaderStyle.Render("Message"))
7286 content.WriteString("\n")
73− content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
87+ content.WriteString(i.currentLog.Message)
7488 content.WriteString("\n\n")
7589
7690 // Attributes section
7791 if len(i.currentLog.Attributes) > 0 {
78− attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
79− content.WriteString(attrHeaderStyle.Render("Attributes:"))
92+ attrHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true)
93+ content.WriteString(attrHeaderStyle.Render("Attributes"))
8094 content.WriteString("\n")
8195
82− // Create a table-like display for attributes
83− keyStyle := lipgloss.NewStyle().Foreground(t.Primary()).Bold(true)
84− valueStyle := lipgloss.NewStyle().Foreground(t.Text())
85−
8696 for _, attr := range i.currentLog.Attributes {
97+ keyStyle := t.S().Base.Foreground(t.Accent)
98+ valueStyle := t.S().Text
8799 attrLine := fmt.Sprintf("%s: %s",
88100 keyStyle.Render(attr.Key),
89101 valueStyle.Render(attr.Value),
90102 )
91− content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine))
103+ content.WriteString(attrLine)
92104 content.WriteString("\n")
93105 }
94106 }
@@ -97,26 +109,53 @@ func (i *detailCmp) updateContent() {
97109}
98110
99111func getLevelStyle(level string) lipgloss.Style {
100− style := lipgloss.NewStyle().Bold(true)
101− t := theme.CurrentTheme()
102−
112+ t := styles.CurrentTheme()
113+ style := t.S().Base.Bold(true)
114+
103115 switch strings.ToLower(level) {
104116 case "info":
105− return style.Foreground(t.Info())
117+ return style.Foreground(t.White).Background(t.Info)
106118 case "warn", "warning":
107− return style.Foreground(t.Warning())
119+ return style.Foreground(t.White).Background(t.Warning)
108120 case "error", "err":
109− return style.Foreground(t.Error())
121+ return style.Foreground(t.White).Background(t.Error)
110122 case "debug":
111− return style.Foreground(t.Success())
123+ return style.Foreground(t.White).Background(t.Success)
124+ case "fatal":
125+ return style.Foreground(t.White).Background(t.Error)
112126 default:
113− return style.Foreground(t.Text())
127+ return style.Foreground(t.FgBase)
128+ }
129+}
130+
131+func getRelativeTime(logTime time.Time) string {
132+ now := time.Now()
133+ diff := now.Sub(logTime)
134+
135+ if diff < time.Minute {
136+ return fmt.Sprintf("%ds ago", int(diff.Seconds()))
137+ } else if diff < time.Hour {
138+ return fmt.Sprintf("%dm ago", int(diff.Minutes()))
139+ } else if diff < 24*time.Hour {
140+ return fmt.Sprintf("%dh ago", int(diff.Hours()))
141+ } else if diff < 30*24*time.Hour {
142+ return fmt.Sprintf("%dd ago", int(diff.Hours()/24))
143+ } else if diff < 365*24*time.Hour {
144+ return fmt.Sprintf("%dmo ago", int(diff.Hours()/(24*30)))
145+ } else {
146+ return fmt.Sprintf("%dy ago", int(diff.Hours()/(24*365)))
114147 }
115148}
116149
117−func (i *detailCmp) View() string {
118− t := theme.CurrentTheme()
119− return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), t.Background())
150+func (i *detailCmp) View() tea.View {
151+ t := styles.CurrentTheme()
152+ style := t.S().Base.
153+ BorderStyle(lipgloss.RoundedBorder()).
154+ BorderForeground(t.BorderFocus).
155+ Width(i.width - 2). // Adjust width for border
156+ Height(i.height - 2). // Adjust height for border
157+ Padding(1)
158+ return tea.NewView(style.Render(i.viewport.View()))
120159}
121160
122161func (i *detailCmp) GetSize() (int, int) {
@@ -124,10 +163,11 @@ func (i *detailCmp) GetSize() (int, int) {
124163}
125164
126165func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
166+ logging.Info("Setting size for detail component", "width", width, "height", height)
127167 i.width = width
128168 i.height = height
129− i.viewport.Width = i.width
130− i.viewport.Height = i.height
169+ i.viewport.SetWidth(i.width - 4)
170+ i.viewport.SetHeight(i.height - 4)
131171 i.updateContent()
132172 return nil
133173}
@@ -138,6 +178,6 @@ func (i *detailCmp) BindingKeys() []key.Binding {
138178
139179func NewLogsDetails() DetailComponent {
140180 return &detailCmp{
141− viewport: viewport.New(0, 0),
181+ viewport: viewport.New(),
142182 }
143183}
internal/tui/components/logs/table.go+124−58
@@ -1,72 +1,105 @@
11package logs
22
33import (
4− "encoding/json"
4+ "fmt"
55 "slices"
6−
7− "github.com/charmbracelet/bubbles/key"
8− "github.com/charmbracelet/bubbles/table"
9− tea "github.com/charmbracelet/bubbletea"
10− "github.com/opencode-ai/opencode/internal/logging"
11− "github.com/opencode-ai/opencode/internal/pubsub"
12− "github.com/opencode-ai/opencode/internal/tui/layout"
13− "github.com/opencode-ai/opencode/internal/tui/styles"
14− "github.com/opencode-ai/opencode/internal/tui/theme"
15− "github.com/opencode-ai/opencode/internal/tui/util"
6+ "strings"
7+
8+ "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/table"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/logging"
12+ "github.com/charmbracelet/crush/internal/pubsub"
13+ "github.com/charmbracelet/crush/internal/tui/layout"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
15+ "github.com/charmbracelet/crush/internal/tui/util"
16+ "github.com/charmbracelet/lipgloss/v2"
1617)
1718
1819type TableComponent interface {
19− tea.Model
20+ util.Model
2021 layout.Sizeable
2122 layout.Bindings
2223}
2324
2425type tableCmp struct {
2526 table table.Model
27+ logs []logging.LogMessage
2628}
2729
2830type selectedLogMsg logging.LogMessage
2931
3032func (i *tableCmp) Init() tea.Cmd {
33+ i.logs = logging.List()
3134 i.setRows()
3235 return nil
3336}
3437
3538func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3639 var cmds []tea.Cmd
37− switch msg.(type) {
40+ switch msg := msg.(type) {
3841 case pubsub.Event[logging.LogMessage]:
39− i.setRows()
40− return i, nil
42+ return i, func() tea.Msg {
43+ if msg.Type == pubsub.CreatedEvent {
44+ rows := i.table.Rows()
45+ for _, row := range rows {
46+ if row[1] == msg.Payload.ID {
47+ return nil // If the log already exists, do not add it again
48+ }
49+ }
50+ i.logs = append(i.logs, msg.Payload)
51+ i.table.SetRows(
52+ append(
53+ []table.Row{
54+ logToRow(msg.Payload),
55+ },
56+ i.table.Rows()...,
57+ ),
58+ )
59+ }
60+ return selectedLogMsg(msg.Payload)
61+ }
4162 }
42− prevSelectedRow := i.table.SelectedRow()
4363 t, cmd := i.table.Update(msg)
4464 cmds = append(cmds, cmd)
4565 i.table = t
46− selectedRow := i.table.SelectedRow()
47− if selectedRow != nil {
48− if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
49− var log logging.LogMessage
50− for _, row := range logging.List() {
51− if row.ID == selectedRow[0] {
52− log = row
53− break
54− }
55− }
56− if log.ID != "" {
57− cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
66+
67+ cmds = append(cmds, func() tea.Msg {
68+ for _, log := range logging.List() {
69+ if log.ID == i.table.SelectedRow()[1] {
70+ // If the selected row matches the log ID, return the selected log message
71+ return selectedLogMsg(log)
5872 }
5973 }
60− }
74+ return nil
75+ })
6176 return i, tea.Batch(cmds...)
6277}
6378
64−func (i *tableCmp) View() string {
65− t := theme.CurrentTheme()
79+func (i *tableCmp) View() tea.View {
80+ t := styles.CurrentTheme()
6681 defaultStyles := table.DefaultStyles()
67− defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
82+
83+ // Header styling
84+ defaultStyles.Header = defaultStyles.Header.
85+ Foreground(t.Primary).
86+ Bold(true).
87+ BorderStyle(lipgloss.NormalBorder()).
88+ BorderBottom(true).
89+ BorderForeground(t.Border)
90+
91+ // Selected row styling
92+ defaultStyles.Selected = defaultStyles.Selected.
93+ Foreground(t.FgSelected).
94+ Background(t.Primary).
95+ Bold(false)
96+
97+ // Cell styling
98+ defaultStyles.Cell = defaultStyles.Cell.
99+ Foreground(t.FgBase)
100+
68101 i.table.SetStyles(defaultStyles)
69− return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), t.Background())
102+ return tea.NewView(i.table.View())
70103}
71104
72105func (i *tableCmp) GetSize() (int, int) {
@@ -76,12 +109,30 @@ func (i *tableCmp) GetSize() (int, int) {
76109func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
77110 i.table.SetWidth(width)
78111 i.table.SetHeight(height)
79− cloumns := i.table.Columns()
80− for i, col := range cloumns {
81− col.Width = (width / len(cloumns)) - 2
82− cloumns[i] = col
83− }
84− i.table.SetColumns(cloumns)
112+
113+ columnWidth := (width - 10) / 4
114+ i.table.SetColumns([]table.Column{
115+ {
116+ Title: "Level",
117+ Width: 10,
118+ },
119+ {
120+ Title: "ID",
121+ Width: columnWidth,
122+ },
123+ {
124+ Title: "Time",
125+ Width: columnWidth,
126+ },
127+ {
128+ Title: "Message",
129+ Width: columnWidth,
130+ },
131+ {
132+ Title: "Attributes",
133+ Width: columnWidth,
134+ },
135+ })
85136 return nil
86137}
87138
@@ -92,39 +143,54 @@ func (i *tableCmp) BindingKeys() []key.Binding {
92143func (i *tableCmp) setRows() {
93144 rows := []table.Row{}
94145
95− logs := logging.List()
96− slices.SortFunc(logs, func(a, b logging.LogMessage) int {
146+ slices.SortFunc(i.logs, func(a, b logging.LogMessage) int {
97147 if a.Time.Before(b.Time) {
98− return 1
148+ return -1
99149 }
100150 if a.Time.After(b.Time) {
101− return -1
151+ return 1
102152 }
103153 return 0
104154 })
105155
106− for _, log := range logs {
107− bm, _ := json.Marshal(log.Attributes)
156+ for _, log := range i.logs {
157+ rows = append(rows, logToRow(log))
158+ }
159+ i.table.SetRows(rows)
160+}
108161
109− row := table.Row{
110− log.ID,
111− log.Time.Format("15:04:05"),
112− log.Level,
113− log.Message,
114− string(bm),
162+func logToRow(log logging.LogMessage) table.Row {
163+ // Format attributes as JSON string
164+ var attrStr string
165+ if len(log.Attributes) > 0 {
166+ var parts []string
167+ for _, attr := range log.Attributes {
168+ parts = append(parts, fmt.Sprintf(`{"Key":"%s","Value":"%s"}`, attr.Key, attr.Value))
115169 }
116− rows = append(rows, row)
170+ attrStr = "[" + strings.Join(parts, ",") + "]"
171+ }
172+
173+ // Format time with relative time
174+ timeStr := log.Time.Format("2006-01-05 15:04:05 UTC")
175+ relativeTime := getRelativeTime(log.Time)
176+ fullTimeStr := timeStr + " " + relativeTime
177+
178+ return table.Row{
179+ strings.ToUpper(log.Level),
180+ log.ID,
181+ fullTimeStr,
182+ log.Message,
183+ attrStr,
117184 }
118− i.table.SetRows(rows)
119185}
120186
121187func NewLogsTable() TableComponent {
122188 columns := []table.Column{
123− {Title: "ID", Width: 4},
124− {Title: "Time", Width: 4},
125− {Title: "Level", Width: 10},
126− {Title: "Message", Width: 10},
127− {Title: "Attributes", Width: 10},
189+ {Title: "Level"},
190+ {Title: "ID"},
191+ {Title: "Time"},
192+ {Title: "Message"},
193+ {Title: "Attributes"},
128194 }
129195
130196 tableModel := table.New(
internal/tui/components/util/simple-list.godeleted−159
@@ -1,159 +0,0 @@
1−package utilComponents
2−
3−import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7− "github.com/opencode-ai/opencode/internal/tui/layout"
8− "github.com/opencode-ai/opencode/internal/tui/styles"
9− "github.com/opencode-ai/opencode/internal/tui/theme"
10−)
11−
12−type SimpleListItem interface {
13− Render(selected bool, width int) string
14−}
15−
16−type SimpleList[T SimpleListItem] interface {
17− tea.Model
18− layout.Bindings
19− SetMaxWidth(maxWidth int)
20− GetSelectedItem() (item T, idx int)
21− SetItems(items []T)
22− GetItems() []T
23−}
24−
25−type simpleListCmp[T SimpleListItem] struct {
26− fallbackMsg string
27− items []T
28− selectedIdx int
29− maxWidth int
30− maxVisibleItems int
31− useAlphaNumericKeys bool
32− width int
33− height int
34−}
35−
36−type simpleListKeyMap struct {
37− Up key.Binding
38− Down key.Binding
39− UpAlpha key.Binding
40− DownAlpha key.Binding
41−}
42−
43−var simpleListKeys = simpleListKeyMap{
44− Up: key.NewBinding(
45− key.WithKeys("up"),
46− key.WithHelp("↑", "previous list item"),
47− ),
48− Down: key.NewBinding(
49− key.WithKeys("down"),
50− key.WithHelp("↓", "next list item"),
51− ),
52− UpAlpha: key.NewBinding(
53− key.WithKeys("k"),
54− key.WithHelp("k", "previous list item"),
55− ),
56− DownAlpha: key.NewBinding(
57− key.WithKeys("j"),
58− key.WithHelp("j", "next list item"),
59− ),
60−}
61−
62−func (c *simpleListCmp[T]) Init() tea.Cmd {
63− return nil
64−}
65−
66−func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
67− switch msg := msg.(type) {
68− case tea.KeyMsg:
69− switch {
70− case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
71− if c.selectedIdx > 0 {
72− c.selectedIdx--
73− }
74− return c, nil
75− case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
76− if c.selectedIdx < len(c.items)-1 {
77− c.selectedIdx++
78− }
79− return c, nil
80− }
81− }
82−
83− return c, nil
84−}
85−
86−func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
87− return layout.KeyMapToSlice(simpleListKeys)
88−}
89−
90−func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
91− if len(c.items) > 0 {
92− return c.items[c.selectedIdx], c.selectedIdx
93− }
94−
95− var zero T
96− return zero, -1
97−}
98−
99−func (c *simpleListCmp[T]) SetItems(items []T) {
100− c.selectedIdx = 0
101− c.items = items
102−}
103−
104−func (c *simpleListCmp[T]) GetItems() []T {
105− return c.items
106−}
107−
108−func (c *simpleListCmp[T]) SetMaxWidth(width int) {
109− c.maxWidth = width
110−}
111−
112−func (c *simpleListCmp[T]) View() string {
113− t := theme.CurrentTheme()
114− baseStyle := styles.BaseStyle()
115−
116− items := c.items
117− maxWidth := c.maxWidth
118− maxVisibleItems := min(c.maxVisibleItems, len(items))
119− startIdx := 0
120−
121− if len(items) <= 0 {
122− return baseStyle.
123− Background(t.Background()).
124− Padding(0, 1).
125− Width(maxWidth).
126− Render(c.fallbackMsg)
127− }
128−
129− if len(items) > maxVisibleItems {
130− halfVisible := maxVisibleItems / 2
131− if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
132− startIdx = c.selectedIdx - halfVisible
133− } else if c.selectedIdx >= len(items)-halfVisible {
134− startIdx = len(items) - maxVisibleItems
135− }
136− }
137−
138− endIdx := min(startIdx+maxVisibleItems, len(items))
139−
140− listItems := make([]string, 0, maxVisibleItems)
141−
142− for i := startIdx; i < endIdx; i++ {
143− item := items[i]
144− title := item.Render(i == c.selectedIdx, maxWidth)
145− listItems = append(listItems, title)
146− }
147−
148− return lipgloss.JoinVertical(lipgloss.Left, listItems...)
149−}
150−
151−func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
152− return &simpleListCmp[T]{
153− fallbackMsg: fallbackMsg,
154− items: items,
155− maxVisibleItems: maxVisibleItems,
156− useAlphaNumericKeys: useAlphaNumericKeys,
157− selectedIdx: 0,
158− }
159−}
internal/tui/image/images.godeleted−72
@@ -1,72 +0,0 @@
1−package image
2−
3−import (
4− "fmt"
5− "image"
6− "os"
7− "strings"
8−
9− "github.com/charmbracelet/lipgloss"
10− "github.com/disintegration/imaging"
11− "github.com/lucasb-eyer/go-colorful"
12−)
13−
14−func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
15− fileInfo, err := os.Stat(filePath)
16− if err != nil {
17− return false, fmt.Errorf("error getting file info: %w", err)
18− }
19−
20− if fileInfo.Size() > sizeLimit {
21− return true, nil
22− }
23−
24− return false, nil
25−}
26−
27−func ToString(width int, img image.Image) string {
28− img = imaging.Resize(img, width, 0, imaging.Lanczos)
29− b := img.Bounds()
30− imageWidth := b.Max.X
31− h := b.Max.Y
32− str := strings.Builder{}
33−
34− for heightCounter := 0; heightCounter < h; heightCounter += 2 {
35− for x := range imageWidth {
36− c1, _ := colorful.MakeColor(img.At(x, heightCounter))
37− color1 := lipgloss.Color(c1.Hex())
38−
39− var color2 lipgloss.Color
40− if heightCounter+1 < h {
41− c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
42− color2 = lipgloss.Color(c2.Hex())
43− } else {
44− color2 = color1
45− }
46−
47− str.WriteString(lipgloss.NewStyle().Foreground(color1).
48− Background(color2).Render("▀"))
49− }
50−
51− str.WriteString("\n")
52− }
53−
54− return str.String()
55−}
56−
57−func ImagePreview(width int, filename string) (string, error) {
58− imageContent, err := os.Open(filename)
59− if err != nil {
60− return "", err
61− }
62− defer imageContent.Close()
63−
64− img, _, err := image.Decode(imageContent)
65− if err != nil {
66− return "", err
67− }
68−
69− imageString := ToString(width, img)
70−
71− return imageString, nil
72−}
internal/tui/keys.gonew file+56
@@ -0,0 +1,56 @@
1+package tui
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Logs key.Binding
10+ Quit key.Binding
11+ Help key.Binding
12+ Commands key.Binding
13+ Sessions key.Binding
14+}
15+
16+func DefaultKeyMap() KeyMap {
17+ return KeyMap{
18+ Logs: key.NewBinding(
19+ key.WithKeys("ctrl+l"),
20+ key.WithHelp("ctrl+l", "logs"),
21+ ),
22+ Quit: key.NewBinding(
23+ key.WithKeys("ctrl+c"),
24+ key.WithHelp("ctrl+c", "quit"),
25+ ),
26+
27+ Help: key.NewBinding(
28+ key.WithKeys("ctrl+_"),
29+ key.WithHelp("ctrl+?", "toggle help"),
30+ ),
31+ Commands: key.NewBinding(
32+ key.WithKeys("ctrl+p"),
33+ key.WithHelp("ctrl+p", "commands"),
34+ ),
35+ Sessions: key.NewBinding(
36+ key.WithKeys("ctrl+s"),
37+ key.WithHelp("ctrl+s", "sessions"),
38+ ),
39+ }
40+}
41+
42+// FullHelp implements help.KeyMap.
43+func (k KeyMap) FullHelp() [][]key.Binding {
44+ m := [][]key.Binding{}
45+ slice := layout.KeyMapToSlice(k)
46+ for i := 0; i < len(slice); i += 4 {
47+ end := min(i+4, len(slice))
48+ m = append(m, slice[i:end])
49+ }
50+ return m
51+}
52+
53+// ShortHelp implements help.KeyMap.
54+func (k KeyMap) ShortHelp() []key.Binding {
55+ return []key.Binding{}
56+}
internal/tui/layout/container.go+81−27
@@ -1,22 +1,28 @@
11package layout
22
33import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7− "github.com/opencode-ai/opencode/internal/tui/theme"
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/tui/styles"
7+ "github.com/charmbracelet/crush/internal/tui/util"
8+ "github.com/charmbracelet/lipgloss/v2"
89)
910
1011type Container interface {
11− tea.Model
12+ util.Model
1213 Sizeable
1314 Bindings
15+ Positionable
16+ Focusable
1417}
1518type container struct {
16− width int
17− height int
19+ width int
20+ height int
21+ isFocused bool
1822
19− content tea.Model
23+ x, y int
24+
25+ content util.Model
2026
2127 // Style options
2228 paddingTop int
@@ -31,23 +37,47 @@ type container struct {
3137 borderStyle lipgloss.Border
3238}
3339
40+type ContainerOption func(*container)
41+
42+func NewContainer(content util.Model, options ...ContainerOption) Container {
43+ c := &container{
44+ content: content,
45+ borderStyle: lipgloss.NormalBorder(),
46+ }
47+
48+ for _, option := range options {
49+ option(c)
50+ }
51+
52+ return c
53+}
54+
3455func (c *container) Init() tea.Cmd {
3556 return c.content.Init()
3657}
3758
3859func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
39− u, cmd := c.content.Update(msg)
40− c.content = u
41− return c, cmd
60+ switch msg := msg.(type) {
61+ case tea.KeyPressMsg:
62+ if c.IsFocused() {
63+ u, cmd := c.content.Update(msg)
64+ c.content = u.(util.Model)
65+ return c, cmd
66+ }
67+ return c, nil
68+ default:
69+ u, cmd := c.content.Update(msg)
70+ c.content = u.(util.Model)
71+ return c, cmd
72+ }
4273}
4374
44−func (c *container) View() string {
45− t := theme.CurrentTheme()
46− style := lipgloss.NewStyle()
75+func (c *container) View() tea.View {
76+ t := styles.CurrentTheme()
4777 width := c.width
4878 height := c.height
4979
50− style = style.Background(t.Background())
80+ style := t.S().Base
5181
5282 // Apply border if any side is enabled
5383 if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
@@ -65,7 +95,7 @@ func (c *container) View() string {
6595 width--
6696 }
6797 style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
68− style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal())
98+ style = style.BorderBackground(t.BgBase).BorderForeground(t.Border)
6999 }
70100 style = style.
71101 Width(width).
@@ -75,7 +105,11 @@ func (c *container) View() string {
75105 PaddingBottom(c.paddingBottom).
76106 PaddingLeft(c.paddingLeft)
77107
78− return style.Render(c.content.View())
108+ contentView := c.content.View()
109+ view := tea.NewView(style.Render(contentView.String()))
110+ cursor := contentView.Cursor()
111+ view.SetCursor(cursor)
112+ return view
79113}
80114
81115func (c *container) SetSize(width, height int) tea.Cmd {
@@ -114,6 +148,15 @@ func (c *container) GetSize() (int, int) {
114148 return c.width, c.height
115149}
116150
151+func (c *container) SetPosition(x, y int) tea.Cmd {
152+ c.x = x
153+ c.y = y
154+ if positionable, ok := c.content.(Positionable); ok {
155+ return positionable.SetPosition(x, y)
156+ }
157+ return nil
158+}
159+
117160func (c *container) BindingKeys() []key.Binding {
118161 if b, ok := c.content.(Bindings); ok {
119162 return b.BindingKeys()
@@ -121,20 +164,31 @@ func (c *container) BindingKeys() []key.Binding {
121164 return []key.Binding{}
122165}
123166
124−type ContainerOption func(*container)
125−
126−func NewContainer(content tea.Model, options ...ContainerOption) Container {
127−
128− c := &container{
129− content: content,
130− borderStyle: lipgloss.NormalBorder(),
167+// Blur implements Container.
168+func (c *container) Blur() tea.Cmd {
169+ c.isFocused = false
170+ if focusable, ok := c.content.(Focusable); ok {
171+ return focusable.Blur()
131172 }
173+ return nil
174+}
132175
133− for _, option := range options {
134− option(c)
176+// Focus implements Container.
177+func (c *container) Focus() tea.Cmd {
178+ c.isFocused = true
179+ if focusable, ok := c.content.(Focusable); ok {
180+ return focusable.Focus()
135181 }
182+ return nil
183+}
136184
137− return c
185+// IsFocused implements Container.
186+func (c *container) IsFocused() bool {
187+ isFocused := c.isFocused
188+ if focusable, ok := c.content.(Focusable); ok {
189+ isFocused = isFocused || focusable.IsFocused()
190+ }
191+ return isFocused
138192}
139193
140194// Padding options
internal/tui/layout/layout.go+11−2
@@ -3,8 +3,9 @@ package layout
33import (
44 "reflect"
55
6− "github.com/charmbracelet/bubbles/key"
7− tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/bubbles/v2/help"
7+ "github.com/charmbracelet/bubbles/v2/key"
8+ tea "github.com/charmbracelet/bubbletea/v2"
89)
910
1011type Focusable interface {
@@ -22,6 +23,14 @@ type Bindings interface {
2223 BindingKeys() []key.Binding
2324}
2425
26+type Positionable interface {
27+ SetPosition(x, y int) tea.Cmd
28+}
29+
30+type Help interface {
31+ Help() help.KeyMap
32+}
33+
2534func KeyMapToSlice(t any) (bindings []key.Binding) {
2635 typ := reflect.TypeOf(t)
2736 if typ.Kind() != reflect.Struct {
internal/tui/layout/overlay.godeleted−169
@@ -1,169 +0,0 @@
1−package layout
2−
3−import (
4− "strings"
5−
6− "github.com/charmbracelet/lipgloss"
7− chAnsi "github.com/charmbracelet/x/ansi"
8− "github.com/muesli/ansi"
9− "github.com/muesli/reflow/truncate"
10− "github.com/muesli/termenv"
11− "github.com/opencode-ai/opencode/internal/tui/styles"
12− "github.com/opencode-ai/opencode/internal/tui/theme"
13− "github.com/opencode-ai/opencode/internal/tui/util"
14−)
15−
16−// Most of this code is borrowed from
17−// https://github.com/charmbracelet/lipgloss/pull/102
18−// as well as the lipgloss library, with some modification for what I needed.
19−
20−// Split a string into lines, additionally returning the size of the widest
21−// line.
22−func getLines(s string) (lines []string, widest int) {
23− lines = strings.Split(s, "\n")
24−
25− for _, l := range lines {
26− w := ansi.PrintableRuneWidth(l)
27− if widest < w {
28− widest = w
29− }
30− }
31−
32− return lines, widest
33−}
34−
35−// PlaceOverlay places fg on top of bg.
36−func PlaceOverlay(
37− x, y int,
38− fg, bg string,
39− shadow bool, opts ...WhitespaceOption,
40−) string {
41− fgLines, fgWidth := getLines(fg)
42− bgLines, bgWidth := getLines(bg)
43− bgHeight := len(bgLines)
44− fgHeight := len(fgLines)
45−
46− if shadow {
47− t := theme.CurrentTheme()
48− baseStyle := styles.BaseStyle()
49−
50− var shadowbg string = ""
51− shadowchar := lipgloss.NewStyle().
52− Background(t.BackgroundDarker()).
53− Foreground(t.Background()).
54− Render("░")
55− bgchar := baseStyle.Render(" ")
56− for i := 0; i <= fgHeight; i++ {
57− if i == 0 {
58− shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"
59− } else {
60− shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n"
61− }
62− }
63−
64− fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...)
65− fgLines, fgWidth = getLines(fg)
66− fgHeight = len(fgLines)
67− }
68−
69− if fgWidth >= bgWidth && fgHeight >= bgHeight {
70− // FIXME: return fg or bg?
71− return fg
72− }
73− // TODO: allow placement outside of the bg box?
74− x = util.Clamp(x, 0, bgWidth-fgWidth)
75− y = util.Clamp(y, 0, bgHeight-fgHeight)
76−
77− ws := &whitespace{}
78− for _, opt := range opts {
79− opt(ws)
80− }
81−
82− var b strings.Builder
83− for i, bgLine := range bgLines {
84− if i > 0 {
85− b.WriteByte('\n')
86− }
87− if i < y || i >= y+fgHeight {
88− b.WriteString(bgLine)
89− continue
90− }
91−
92− pos := 0
93− if x > 0 {
94− left := truncate.String(bgLine, uint(x))
95− pos = ansi.PrintableRuneWidth(left)
96− b.WriteString(left)
97− if pos < x {
98− b.WriteString(ws.render(x - pos))
99− pos = x
100− }
101− }
102−
103− fgLine := fgLines[i-y]
104− b.WriteString(fgLine)
105− pos += ansi.PrintableRuneWidth(fgLine)
106−
107− right := cutLeft(bgLine, pos)
108− bgWidth := ansi.PrintableRuneWidth(bgLine)
109− rightWidth := ansi.PrintableRuneWidth(right)
110− if rightWidth <= bgWidth-pos {
111− b.WriteString(ws.render(bgWidth - rightWidth - pos))
112− }
113−
114− b.WriteString(right)
115− }
116−
117− return b.String()
118−}
119−
120−// cutLeft cuts printable characters from the left.
121−// This function is heavily based on muesli's ansi and truncate packages.
122−func cutLeft(s string, cutWidth int) string {
123− return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
124−}
125−
126−func max(a, b int) int {
127− if a > b {
128− return a
129− }
130− return b
131−}
132−
133−type whitespace struct {
134− style termenv.Style
135− chars string
136−}
137−
138−// Render whitespaces.
139−func (w whitespace) render(width int) string {
140− if w.chars == "" {
141− w.chars = " "
142− }
143−
144− r := []rune(w.chars)
145− j := 0
146− b := strings.Builder{}
147−
148− // Cycle through runes and print them into the whitespace.
149− for i := 0; i < width; {
150− b.WriteRune(r[j])
151− j++
152− if j >= len(r) {
153− j = 0
154− }
155− i += ansi.PrintableRuneWidth(string(r[j]))
156− }
157−
158− // Fill any extra gaps white spaces. This might be necessary if any runes
159− // are more than one cell wide, which could leave a one-rune gap.
160− short := width - ansi.PrintableRuneWidth(b.String())
161− if short > 0 {
162− b.WriteString(strings.Repeat(" ", short))
163− }
164−
165− return w.style.Styled(b.String())
166−}
167−
168−// WhitespaceOption sets a styling rule for rendering whitespace.
169−type WhitespaceOption func(*whitespace)
internal/tui/layout/split.go+110−30
@@ -1,14 +1,23 @@
11package layout
22
33import (
4− "github.com/charmbracelet/bubbles/key"
5− tea "github.com/charmbracelet/bubbletea"
6− "github.com/charmbracelet/lipgloss"
7− "github.com/opencode-ai/opencode/internal/tui/theme"
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/tui/styles"
7+ "github.com/charmbracelet/crush/internal/tui/util"
8+ "github.com/charmbracelet/lipgloss/v2"
9+)
10+
11+type LayoutPanel string
12+
13+const (
14+ LeftPanel LayoutPanel = "left"
15+ RightPanel LayoutPanel = "right"
16+ BottomPanel LayoutPanel = "bottom"
817)
918
1019type SplitPaneLayout interface {
11− tea.Model
20+ util.Model
1221 Sizeable
1322 Bindings
1423 SetLeftPanel(panel Container) tea.Cmd
@@ -18,17 +27,23 @@ type SplitPaneLayout interface {
1827 ClearLeftPanel() tea.Cmd
1928 ClearRightPanel() tea.Cmd
2029 ClearBottomPanel() tea.Cmd
30+
31+ FocusPanel(panel LayoutPanel) tea.Cmd
2132}
2233
2334type splitPaneLayout struct {
24− width int
25− height int
35+ width int
36+ height int
37+
2638 ratio float64
2739 verticalRatio float64
2840
2941 rightPanel Container
3042 leftPanel Container
3143 bottomPanel Container
44+
45+ fixedBottomHeight int // Fixed height for the bottom panel, if any
46+ fixedRightWidth int // Fixed width for the right panel, if any
3247}
3348
3449type SplitPaneOption func(*splitPaneLayout)
@@ -85,17 +100,17 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85100 return s, tea.Batch(cmds...)
86101}
87102
88−func (s *splitPaneLayout) View() string {
103+func (s *splitPaneLayout) View() tea.View {
89104 var topSection string
90105
91106 if s.leftPanel != nil && s.rightPanel != nil {
92107 leftView := s.leftPanel.View()
93108 rightView := s.rightPanel.View()
94− topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
109+ topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
95110 } else if s.leftPanel != nil {
96− topSection = s.leftPanel.View()
111+ topSection = s.leftPanel.View().String()
97112 } else if s.rightPanel != nil {
98− topSection = s.rightPanel.View()
113+ topSection = s.rightPanel.View().String()
99114 } else {
100115 topSection = ""
101116 }
@@ -104,25 +119,32 @@ func (s *splitPaneLayout) View() string {
104119
105120 if s.bottomPanel != nil && topSection != "" {
106121 bottomView := s.bottomPanel.View()
107− finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
122+ finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
108123 } else if s.bottomPanel != nil {
109− finalView = s.bottomPanel.View()
124+ finalView = s.bottomPanel.View().String()
110125 } else {
111126 finalView = topSection
112127 }
113128
114− if finalView != "" {
115− t := theme.CurrentTheme()
129+ // TODO: think of a better way to handle multiple cursors
130+ var cursor *tea.Cursor
131+ if s.bottomPanel != nil {
132+ cursor = s.bottomPanel.View().Cursor()
133+ } else if s.rightPanel != nil {
134+ cursor = s.rightPanel.View().Cursor()
135+ } else if s.leftPanel != nil {
136+ cursor = s.leftPanel.View().Cursor()
137+ }
116138
117− style := lipgloss.NewStyle().
118− Width(s.width).
119− Height(s.height).
120− Background(t.Background())
139+ t := styles.CurrentTheme()
121140
122− return style.Render(finalView)
123− }
141+ style := t.S().Base.
142+ Width(s.width).
143+ Height(s.height)
124144
125− return finalView
145+ view := tea.NewView(style.Render(finalView))
146+ view.SetCursor(cursor)
147+ return view
126148}
127149
128150func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
@@ -130,9 +152,19 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
130152 s.height = height
131153
132154 var topHeight, bottomHeight int
155+ var cmds []tea.Cmd
133156 if s.bottomPanel != nil {
134− topHeight = int(float64(height) * s.verticalRatio)
135− bottomHeight = height - topHeight
157+ if s.fixedBottomHeight > 0 {
158+ bottomHeight = s.fixedBottomHeight
159+ topHeight = height - bottomHeight
160+ } else {
161+ topHeight = int(float64(height) * s.verticalRatio)
162+ bottomHeight = height - topHeight
163+ if bottomHeight <= 0 {
164+ bottomHeight = 2
165+ topHeight = height - bottomHeight
166+ }
167+ }
136168 } else {
137169 topHeight = height
138170 bottomHeight = 0
@@ -140,8 +172,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
140172
141173 var leftWidth, rightWidth int
142174 if s.leftPanel != nil && s.rightPanel != nil {
143− leftWidth = int(float64(width) * s.ratio)
144− rightWidth = width - leftWidth
175+ if s.fixedRightWidth > 0 {
176+ rightWidth = s.fixedRightWidth
177+ leftWidth = width - rightWidth
178+ } else {
179+ leftWidth = int(float64(width) * s.ratio)
180+ rightWidth = width - leftWidth
181+ if rightWidth <= 0 {
182+ rightWidth = 2
183+ leftWidth = width - rightWidth
184+ }
185+ }
145186 } else if s.leftPanel != nil {
146187 leftWidth = width
147188 rightWidth = 0
@@ -150,20 +191,28 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
150191 rightWidth = width
151192 }
152193
153− var cmds []tea.Cmd
154194 if s.leftPanel != nil {
155195 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
156196 cmds = append(cmds, cmd)
197+ if positionable, ok := s.leftPanel.(Positionable); ok {
198+ cmds = append(cmds, positionable.SetPosition(0, 0))
199+ }
157200 }
158201
159202 if s.rightPanel != nil {
160203 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
161204 cmds = append(cmds, cmd)
205+ if positionable, ok := s.rightPanel.(Positionable); ok {
206+ cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
207+ }
162208 }
163209
164210 if s.bottomPanel != nil {
165211 cmd := s.bottomPanel.SetSize(width, bottomHeight)
166212 cmds = append(cmds, cmd)
213+ if positionable, ok := s.bottomPanel.(Positionable); ok {
214+ cmds = append(cmds, positionable.SetPosition(0, topHeight))
215+ }
167216 }
168217 return tea.Batch(cmds...)
169218}
@@ -240,11 +289,30 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
240289 return keys
241290}
242291
243−func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
292+func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
293+ panels := map[LayoutPanel]Container{
294+ LeftPanel: s.leftPanel,
295+ RightPanel: s.rightPanel,
296+ BottomPanel: s.bottomPanel,
297+ }
298+ var cmds []tea.Cmd
299+ for p, container := range panels {
300+ if container == nil {
301+ continue
302+ }
303+ if p == panel {
304+ cmds = append(cmds, container.Focus())
305+ } else {
306+ cmds = append(cmds, container.Blur())
307+ }
308+ }
309+ return tea.Batch(cmds...)
310+}
244311
312+func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
245313 layout := &splitPaneLayout{
246− ratio: 0.7,
247− verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
314+ ratio: 0.8,
315+ verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
248316 }
249317 for _, option := range options {
250318 option(layout)
@@ -281,3 +349,15 @@ func WithVerticalRatio(ratio float64) SplitPaneOption {
281349 s.verticalRatio = ratio
282350 }
283351}
352+
353+func WithFixedBottomHeight(height int) SplitPaneOption {
354+ return func(s *splitPaneLayout) {
355+ s.fixedBottomHeight = height
356+ }
357+}
358+
359+func WithFixedRightWidth(width int) SplitPaneOption {
360+ return func(s *splitPaneLayout) {
361+ s.fixedRightWidth = width
362+ }
363+}
internal/tui/page/chat.godeleted−237
@@ -1,237 +0,0 @@
1−package page
2−
3−import (
4− "context"
5− "strings"
6−
7− "github.com/charmbracelet/bubbles/key"
8− tea "github.com/charmbracelet/bubbletea"
9− "github.com/charmbracelet/lipgloss"
10− "github.com/opencode-ai/opencode/internal/app"
11− "github.com/opencode-ai/opencode/internal/completions"
12− "github.com/opencode-ai/opencode/internal/message"
13− "github.com/opencode-ai/opencode/internal/session"
14− "github.com/opencode-ai/opencode/internal/tui/components/chat"
15− "github.com/opencode-ai/opencode/internal/tui/components/dialog"
16− "github.com/opencode-ai/opencode/internal/tui/layout"
17− "github.com/opencode-ai/opencode/internal/tui/util"
18−)
19−
20−var ChatPage PageID = "chat"
21−
22−type chatPage struct {
23− app *app.App
24− editor layout.Container
25− messages layout.Container
26− layout layout.SplitPaneLayout
27− session session.Session
28− completionDialog dialog.CompletionDialog
29− showCompletionDialog bool
30−}
31−
32−type ChatKeyMap struct {
33− ShowCompletionDialog key.Binding
34− NewSession key.Binding
35− Cancel key.Binding
36−}
37−
38−var keyMap = ChatKeyMap{
39− ShowCompletionDialog: key.NewBinding(
40− key.WithKeys("@"),
41− key.WithHelp("@", "Complete"),
42− ),
43− NewSession: key.NewBinding(
44− key.WithKeys("ctrl+n"),
45− key.WithHelp("ctrl+n", "new session"),
46− ),
47− Cancel: key.NewBinding(
48− key.WithKeys("esc"),
49− key.WithHelp("esc", "cancel"),
50− ),
51−}
52−
53−func (p *chatPage) Init() tea.Cmd {
54− cmds := []tea.Cmd{
55− p.layout.Init(),
56− p.completionDialog.Init(),
57− }
58− return tea.Batch(cmds...)
59−}
60−
61−func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62− var cmds []tea.Cmd
63− switch msg := msg.(type) {
64− case tea.WindowSizeMsg:
65− cmd := p.layout.SetSize(msg.Width, msg.Height)
66− cmds = append(cmds, cmd)
67− case dialog.CompletionDialogCloseMsg:
68− p.showCompletionDialog = false
69− case chat.SendMsg:
70− cmd := p.sendMessage(msg.Text, msg.Attachments)
71− if cmd != nil {
72− return p, cmd
73− }
74− case dialog.CommandRunCustomMsg:
75− // Check if the agent is busy before executing custom commands
76− if p.app.CoderAgent.IsBusy() {
77− return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
78− }
79−
80− // Process the command content with arguments if any
81− content := msg.Content
82− if msg.Args != nil {
83− // Replace all named arguments with their values
84− for name, value := range msg.Args {
85− placeholder := "$" + name
86− content = strings.ReplaceAll(content, placeholder, value)
87− }
88− }
89−
90− // Handle custom command execution
91− cmd := p.sendMessage(content, nil)
92− if cmd != nil {
93− return p, cmd
94− }
95− case chat.SessionSelectedMsg:
96− if p.session.ID == "" {
97− cmd := p.setSidebar()
98− if cmd != nil {
99− cmds = append(cmds, cmd)
100− }
101− }
102− p.session = msg
103− case tea.KeyMsg:
104− switch {
105− case key.Matches(msg, keyMap.ShowCompletionDialog):
106− p.showCompletionDialog = true
107− // Continue sending keys to layout->chat
108− case key.Matches(msg, keyMap.NewSession):
109− p.session = session.Session{}
110− return p, tea.Batch(
111− p.clearSidebar(),
112− util.CmdHandler(chat.SessionClearedMsg{}),
113− )
114− case key.Matches(msg, keyMap.Cancel):
115− if p.session.ID != "" {
116− // Cancel the current session's generation process
117− // This allows users to interrupt long-running operations
118− p.app.CoderAgent.Cancel(p.session.ID)
119− return p, nil
120− }
121− }
122− }
123− if p.showCompletionDialog {
124− context, contextCmd := p.completionDialog.Update(msg)
125− p.completionDialog = context.(dialog.CompletionDialog)
126− cmds = append(cmds, contextCmd)
127−
128− // Doesn't forward event if enter key is pressed
129− if keyMsg, ok := msg.(tea.KeyMsg); ok {
130− if keyMsg.String() == "enter" {
131− return p, tea.Batch(cmds...)
132− }
133− }
134− }
135−
136− u, cmd := p.layout.Update(msg)
137− cmds = append(cmds, cmd)
138− p.layout = u.(layout.SplitPaneLayout)
139−
140− return p, tea.Batch(cmds...)
141−}
142−
143−func (p *chatPage) setSidebar() tea.Cmd {
144− sidebarContainer := layout.NewContainer(
145− chat.NewSidebarCmp(p.session, p.app.History),
146− layout.WithPadding(1, 1, 1, 1),
147− )
148− return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
149−}
150−
151−func (p *chatPage) clearSidebar() tea.Cmd {
152− return p.layout.ClearRightPanel()
153−}
154−
155−func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
156− var cmds []tea.Cmd
157− if p.session.ID == "" {
158− session, err := p.app.Sessions.Create(context.Background(), "New Session")
159− if err != nil {
160− return util.ReportError(err)
161− }
162−
163− p.session = session
164− cmd := p.setSidebar()
165− if cmd != nil {
166− cmds = append(cmds, cmd)
167− }
168− cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
169− }
170−
171− _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
172− if err != nil {
173− return util.ReportError(err)
174− }
175− return tea.Batch(cmds...)
176−}
177−
178−func (p *chatPage) SetSize(width, height int) tea.Cmd {
179− return p.layout.SetSize(width, height)
180−}
181−
182−func (p *chatPage) GetSize() (int, int) {
183− return p.layout.GetSize()
184−}
185−
186−func (p *chatPage) View() string {
187− layoutView := p.layout.View()
188−
189− if p.showCompletionDialog {
190− _, layoutHeight := p.layout.GetSize()
191− editorWidth, editorHeight := p.editor.GetSize()
192−
193− p.completionDialog.SetWidth(editorWidth)
194− overlay := p.completionDialog.View()
195−
196− layoutView = layout.PlaceOverlay(
197− 0,
198− layoutHeight-editorHeight-lipgloss.Height(overlay),
199− overlay,
200− layoutView,
201− false,
202− )
203− }
204−
205− return layoutView
206−}
207−
208−func (p *chatPage) BindingKeys() []key.Binding {
209− bindings := layout.KeyMapToSlice(keyMap)
210− bindings = append(bindings, p.messages.BindingKeys()...)
211− bindings = append(bindings, p.editor.BindingKeys()...)
212− return bindings
213−}
214−
215−func NewChatPage(app *app.App) tea.Model {
216− cg := completions.NewFileAndFolderContextGroup()
217− completionDialog := dialog.NewCompletionDialogCmp(cg)
218−
219− messagesContainer := layout.NewContainer(
220− chat.NewMessagesCmp(app),
221− layout.WithPadding(1, 1, 0, 1),
222− )
223− editorContainer := layout.NewContainer(
224− chat.NewEditorCmp(app),
225− layout.WithBorder(true, false, false, false),
226− )
227− return &chatPage{
228− app: app,
229− editor: editorContainer,
230− messages: messagesContainer,
231− completionDialog: completionDialog,
232− layout: layout.NewSplitPane(
233− layout.WithLeftPanel(messagesContainer),
234− layout.WithBottomPanel(editorContainer),
235− ),
236− }
237−}
internal/tui/page/chat/chat.gonew file+194
@@ -0,0 +1,194 @@
1+package chat
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/bubbles/v2/key"
7+ tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/app"
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/llm/models"
11+ "github.com/charmbracelet/crush/internal/message"
12+ "github.com/charmbracelet/crush/internal/session"
13+ "github.com/charmbracelet/crush/internal/tui/components/chat"
14+ "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
15+ "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
16+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
17+ "github.com/charmbracelet/crush/internal/tui/layout"
18+ "github.com/charmbracelet/crush/internal/tui/page"
19+ "github.com/charmbracelet/crush/internal/tui/util"
20+)
21+
22+var ChatPage page.PageID = "chat"
23+
24+type ChatFocusedMsg struct {
25+ Focused bool // True if the chat input is focused, false otherwise
26+}
27+
28+type (
29+ OpenFilePickerMsg struct{}
30+ chatPage struct {
31+ app *app.App
32+
33+ layout layout.SplitPaneLayout
34+
35+ session session.Session
36+
37+ keyMap KeyMap
38+
39+ chatFocused bool
40+ }
41+)
42+
43+func (p *chatPage) Init() tea.Cmd {
44+ cmd := p.layout.Init()
45+ return tea.Batch(
46+ cmd,
47+ p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor)
48+ )
49+}
50+
51+func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
52+ var cmds []tea.Cmd
53+ switch msg := msg.(type) {
54+ case tea.WindowSizeMsg:
55+ cmd := p.layout.SetSize(msg.Width, msg.Height)
56+ cmds = append(cmds, cmd)
57+ case chat.SendMsg:
58+ cmd := p.sendMessage(msg.Text, msg.Attachments)
59+ if cmd != nil {
60+ return p, cmd
61+ }
62+ case commands.CommandRunCustomMsg:
63+ // Check if the agent is busy before executing custom commands
64+ if p.app.CoderAgent.IsBusy() {
65+ return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
66+ }
67+
68+ // Handle custom command execution
69+ cmd := p.sendMessage(msg.Content, nil)
70+ if cmd != nil {
71+ return p, cmd
72+ }
73+ case chat.SessionSelectedMsg:
74+ if p.session.ID == "" {
75+ cmd := p.setMessages()
76+ if cmd != nil {
77+ cmds = append(cmds, cmd)
78+ }
79+ }
80+ p.session = msg
81+ case tea.KeyPressMsg:
82+ switch {
83+ case key.Matches(msg, p.keyMap.NewSession):
84+ p.session = session.Session{}
85+ return p, tea.Batch(
86+ p.clearMessages(),
87+ util.CmdHandler(chat.SessionClearedMsg{}),
88+ )
89+
90+ case key.Matches(msg, p.keyMap.FilePicker):
91+ cfg := config.Get()
92+ agentCfg := cfg.Agents[config.AgentCoder]
93+ selectedModelID := agentCfg.Model
94+ model := models.SupportedModels[selectedModelID]
95+ if model.SupportsAttachments {
96+ return p, util.CmdHandler(OpenFilePickerMsg{})
97+ } else {
98+ return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
99+ }
100+ case key.Matches(msg, p.keyMap.Tab):
101+ if p.session.ID == "" {
102+ return p, nil
103+ }
104+ p.chatFocused = !p.chatFocused
105+ if p.chatFocused {
106+ cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
107+ cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
108+ } else {
109+ cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
110+ cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
111+ }
112+ return p, tea.Batch(cmds...)
113+ case key.Matches(msg, p.keyMap.Cancel):
114+ if p.session.ID != "" {
115+ // Cancel the current session's generation process
116+ // This allows users to interrupt long-running operations
117+ p.app.CoderAgent.Cancel(p.session.ID)
118+ return p, nil
119+ }
120+ }
121+ }
122+ u, cmd := p.layout.Update(msg)
123+ cmds = append(cmds, cmd)
124+ p.layout = u.(layout.SplitPaneLayout)
125+
126+ return p, tea.Batch(cmds...)
127+}
128+
129+func (p *chatPage) setMessages() tea.Cmd {
130+ messagesContainer := layout.NewContainer(
131+ chat.NewMessagesListCmp(p.app),
132+ layout.WithPadding(1, 1, 0, 1),
133+ )
134+ return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
135+}
136+
137+func (p *chatPage) clearMessages() tea.Cmd {
138+ return p.layout.ClearLeftPanel()
139+}
140+
141+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
142+ var cmds []tea.Cmd
143+ if p.session.ID == "" {
144+ session, err := p.app.Sessions.Create(context.Background(), "New Session")
145+ if err != nil {
146+ return util.ReportError(err)
147+ }
148+
149+ p.session = session
150+ cmd := p.setMessages()
151+ if cmd != nil {
152+ cmds = append(cmds, cmd)
153+ }
154+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
155+ }
156+
157+ _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
158+ if err != nil {
159+ return util.ReportError(err)
160+ }
161+ return tea.Batch(cmds...)
162+}
163+
164+func (p *chatPage) SetSize(width, height int) tea.Cmd {
165+ return p.layout.SetSize(width, height)
166+}
167+
168+func (p *chatPage) GetSize() (int, int) {
169+ return p.layout.GetSize()
170+}
171+
172+func (p *chatPage) View() tea.View {
173+ return p.layout.View()
174+}
175+
176+func NewChatPage(app *app.App) util.Model {
177+ sidebarContainer := layout.NewContainer(
178+ sidebar.NewSidebarCmp(),
179+ layout.WithPadding(1, 1, 1, 1),
180+ )
181+ editorContainer := layout.NewContainer(
182+ editor.NewEditorCmp(app),
183+ )
184+ return &chatPage{
185+ app: app,
186+ layout: layout.NewSplitPane(
187+ layout.WithRightPanel(sidebarContainer),
188+ layout.WithBottomPanel(editorContainer),
189+ layout.WithFixedBottomHeight(5),
190+ layout.WithFixedRightWidth(31),
191+ ),
192+ keyMap: DefaultKeyMap(),
193+ }
194+}
internal/tui/page/chat/keys.gonew file+52
@@ -0,0 +1,52 @@
1+package chat
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/charmbracelet/crush/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ NewSession key.Binding
10+ FilePicker key.Binding
11+ Cancel key.Binding
12+ Tab key.Binding
13+}
14+
15+func DefaultKeyMap() KeyMap {
16+ return KeyMap{
17+ NewSession: key.NewBinding(
18+ key.WithKeys("ctrl+n"),
19+ key.WithHelp("ctrl+n", "new session"),
20+ ),
21+ Cancel: key.NewBinding(
22+ key.WithKeys("esc"),
23+ key.WithHelp("esc", "cancel"),
24+ ),
25+ Tab: key.NewBinding(
26+ key.WithKeys("tab"),
27+ key.WithHelp("tab", "change focus"),
28+ ),
29+ FilePicker: key.NewBinding(
30+ key.WithKeys("ctrl+f"),
31+ key.WithHelp("ctrl+f", "select files to upload"),
32+ ),
33+ }
34+}
35+
36+// FullHelp implements help.KeyMap.
37+func (k KeyMap) FullHelp() [][]key.Binding {
38+ m := [][]key.Binding{}
39+ slice := layout.KeyMapToSlice(k)
40+ for i := 0; i < len(slice); i += 4 {
41+ end := min(i+4, len(slice))
42+ m = append(m, slice[i:end])
43+ }
44+ return m
45+}
46+
47+// ShortHelp implements help.KeyMap.
48+func (k KeyMap) ShortHelp() []key.Binding {
49+ return []key.Binding{
50+ k.Tab,
51+ }
52+}
internal/tui/page/logs.godeleted−83

This diff is too big to show here.

internal/tui/page/logs/keys.gonew file+37

This diff is too big to show here.

internal/tui/page/logs/logs.gonew file+100

This diff is too big to show here.

internal/tui/styles/background.godeleted−123

This diff is too big to show here.

internal/tui/styles/chroma.gonew file+79

This diff is too big to show here.

internal/tui/styles/crush.gonew file+51

This diff is too big to show here.

internal/tui/styles/icons.go+6−3

This diff is too big to show here.

internal/tui/styles/markdown.go+3−267

This diff is too big to show here.

internal/tui/styles/styles.godeleted−155

This diff is too big to show here.

internal/tui/styles/theme.gonew file+630

This diff is too big to show here.

internal/tui/theme/catppuccin.godeleted−248

This diff is too big to show here.

internal/tui/theme/dracula.godeleted−274

This diff is too big to show here.

internal/tui/theme/flexoki.godeleted−282

This diff is too big to show here.

internal/tui/theme/gruvbox.godeleted−302

This diff is too big to show here.

internal/tui/theme/manager.godeleted−118

This diff is too big to show here.

internal/tui/theme/monokai.godeleted−273

This diff is too big to show here.

internal/tui/theme/onedark.godeleted−274

This diff is too big to show here.

internal/tui/theme/opencode.godeleted−277

This diff is too big to show here.

internal/tui/theme/theme.godeleted−208

This diff is too big to show here.

internal/tui/theme/theme_test.godeleted−89

This diff is too big to show here.

internal/tui/theme/tokyonight.godeleted−274

This diff is too big to show here.

internal/tui/theme/tron.godeleted−276

This diff is too big to show here.

internal/tui/tui.go+310−840

This diff is too big to show here.

internal/tui/util/util.go+6−1

This diff is too big to show here.

internal/version/version.go+1−1

This diff is too big to show here.

main.go+16−2

This diff is too big to show here.

todos.mdnew file+29

This diff is too big to show here.