on main help sign in
Merge branch 'main' into diff-overhaul
a092ca3Andrey Nering
.opencode.json → .crush.jsonrenamed+4−1
@@ -1,8 +1,11 @@
1{
1{
2 "$schema": "./opencode-schema.json",
2 "$schema": "./opencode-schema.json",
3 "lsp": {
3 "lsp": {
4− "gopls": {
4+ "Go": {
5 "command": "gopls"
5 "command": "gopls"
6 }
6 }
7+ },
8+ "tui": {
9+ "theme": "charm"
7 }
10 }
8}
11}
.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
41.env
41.env
42.env.local
42.env.local
43
43
44−.opencode/
44+.crush/
45
45
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
2
2
3<p align="center"><img src="https://github.com/user-attachments/assets/9ae61ef6-70e5-4876-bc45-5bcb4e52c714" width="800"></p>
3<p align="center"><img src="https://github.com/user-attachments/assets/9ae61ef6-70e5-4876-bc45-5bcb4e52c714" width="800"></p>
4
4
@@ -8,10 +8,10 @@ A powerful terminal-based AI assistant for developers, providing intelligent cod
8
8
9## Overview
9## Overview
10
10
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.
12
12
13<p>For a quick video overview, check out
13<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>
15
15
16<a href="https://www.youtube.com/watch?v=P8luPmEa1QI"><img width="550" src="https://i3.ytimg.com/vi/P8luPmEa1QI/maxresdefault.jpg"></a><p>
16<a href="https://www.youtube.com/watch?v=P8luPmEa1QI"><img width="550" src="https://i3.ytimg.com/vi/P8luPmEa1QI/maxresdefault.jpg"></a><p>
17
17
@@ -34,45 +34,45 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
34
34
35```bash
35```bash
36# Install the latest version
36# 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
38
38
39# Install a specific version
39# 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
41```
41```
42
42
43### Using Homebrew (macOS and Linux)
43### Using Homebrew (macOS and Linux)
44
44
45```bash
45```bash
46−brew install opencode-ai/tap/opencode
46+brew install crush-ai/tap/crush
47```
47```
48
48
49### Using AUR (Arch Linux)
49### Using AUR (Arch Linux)
50
50
51```bash
51```bash
52# Using yay
52# Using yay
53−yay -S opencode-ai-bin
53+yay -S crush-ai-bin
54
54
55# Using paru
55# Using paru
56−paru -S opencode-ai-bin
56+paru -S crush-ai-bin
57```
57```
58
58
59### Using Go
59### Using Go
60
60
61```bash
61```bash
62−go install github.com/opencode-ai/opencode@latest
62+go install github.com/charmbracelet/crush@latest
63```
63```
64
64
65## Configuration
65## Configuration
66
66
67−OpenCode looks for configuration in the following locations:
67+Crush looks for configuration in the following locations:
68
68
69−- `$HOME/.opencode.json`
69+- `$HOME/.crush.json`
70−- `$XDG_CONFIG_HOME/opencode/.opencode.json`
70+- `$XDG_CONFIG_HOME/crush/.crush.json`
71−- `./.opencode.json` (local directory)
71+- `./.crush.json` (local directory)
72
72
73### Auto Compact Feature
73### Auto Compact Feature
74
74
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:
76
76
77- Monitors token usage during your conversation
77- Monitors token usage during your conversation
78- Automatically triggers summarization when usage reaches 95% of the model's context window
78- 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:
89
89
90### Environment Variables
90### Environment Variables
91
91
92−You can configure OpenCode using environment variables:
92+You can configure Crush using environment variables:
93
93
94| Environment Variable | Purpose |
94| Environment Variable | Purpose |
95| -------------------------- | ------------------------------------------------------ |
95| -------------------------- | ------------------------------------------------------ |
@@ -110,7 +110,7 @@ You can configure OpenCode using environment variables:
110
110
111### Shell Configuration
111### Shell Configuration
112
112
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.
114
114
115You can override this in your configuration file:
115You can override this in your configuration file:
116
116
@@ -130,7 +130,7 @@ This is useful if you want to use a different shell than your default system she
130```json
130```json
131{
131{
132 "data": {
132 "data": {
133− "directory": ".opencode"
133+ "directory": ".crush"
134 },
134 },
135 "providers": {
135 "providers": {
136 "openai": {
136 "openai": {
@@ -190,7 +190,7 @@ This is useful if you want to use a different shell than your default system she
190
190
191## Supported AI Models
191## Supported AI Models
192
192
193−OpenCode supports a variety of AI models from different providers:
193+Crush supports a variety of AI models from different providers:
194
194
195### OpenAI
195### OpenAI
196
196
@@ -247,38 +247,38 @@ OpenCode supports a variety of AI models from different providers:
247## Usage
247## Usage
248
248
249```bash
249```bash
250−# Start OpenCode
250+# Start Crush
251−opencode
251+crush
252
252
253# Start with debug logging
253# Start with debug logging
254−opencode -d
254+crush -d
255
255
256# Start with a specific working directory
256# Start with a specific working directory
257−opencode -c /path/to/project
257+crush -c /path/to/project
258```
258```
259
259
260## Non-interactive Prompt Mode
260## Non-interactive Prompt Mode
261
261
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.
263
263
264```bash
264```bash
265# Run a single prompt and print the AI's response to the terminal
265# 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"
267
267
268# Get response in JSON format
268# 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
270
270
271# Run without showing the spinner (useful for scripts)
271# 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
273```
273```
274
274
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.
276
276
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.
278
278
279### Output Formats
279### Output Formats
280
280
281−OpenCode supports the following output formats in non-interactive mode:
281+Crush supports the following output formats in non-interactive mode:
282
282
283| Format | Description |
283| Format | Description |
284| ------ | ------------------------------- |
284| ------ | ------------------------------- |
@@ -369,7 +369,7 @@ The output format is implemented as a strongly-typed `OutputFormat` in the codeb
369
369
370## AI Assistant Tools
370## AI Assistant Tools
371
371
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:
373
373
374### File and Code Tools
374### File and Code Tools
375
375
@@ -395,7 +395,7 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
395
395
396## Architecture
396## Architecture
397
397
398−OpenCode is built with a modular architecture:
398+Crush is built with a modular architecture:
399
399
400- **cmd**: Command-line interface using Cobra
400- **cmd**: Command-line interface using Cobra
401- **internal/app**: Core application services
401- **internal/app**: Core application services
@@ -410,7 +410,7 @@ OpenCode is built with a modular architecture:
410
410
411## Custom Commands
411## Custom Commands
412
412
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.
414
414
415### Creating Custom Commands
415### Creating Custom Commands
416
416
@@ -419,26 +419,26 @@ Custom commands are predefined prompts stored as Markdown files in one of three
4191. **User Commands** (prefixed with `user:`):
4191. **User Commands** (prefixed with `user:`):
420
420
421 ```
421 ```
422− $XDG_CONFIG_HOME/opencode/commands/
422+ $XDG_CONFIG_HOME/crush/commands/
423 ```
423 ```
424
424
425− (typically `~/.config/opencode/commands/` on Linux/macOS)
425+ (typically `~/.config/crush/commands/` on Linux/macOS)
426
426
427 or
427 or
428
428
429 ```
429 ```
430− $HOME/.opencode/commands/
430+ $HOME/.crush/commands/
431 ```
431 ```
432
432
4332. **Project Commands** (prefixed with `project:`):
4332. **Project Commands** (prefixed with `project:`):
434
434
435 ```
435 ```
436− <PROJECT DIR>/.opencode/commands/
436+ <PROJECT DIR>/.crush/commands/
437 ```
437 ```
438
438
439Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
439Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
440
440
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:
442
442
443```markdown
443```markdown
444RUN git ls-files
444RUN git ls-files
@@ -449,7 +449,7 @@ This creates a command called `user:prime-context`.
449
449
450### Command Arguments
450### Command Arguments
451
451
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).
453
453
454For example:
454For example:
455
455
@@ -461,7 +461,7 @@ RUN git grep --author="$AUTHOR_NAME" -n .
461RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
461RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
462```
462```
463
463
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:
465
465
466- Clear identification of what each argument represents
466- Clear identification of what each argument represents
467- Ability to use the same argument multiple times
467- 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
472You can organize commands in subdirectories:
472You can organize commands in subdirectories:
473
473
474```
474```
475−~/.config/opencode/commands/git/commit.md
475+~/.config/crush/commands/git/commit.md
476```
476```
477
477
478This creates a command with ID `user:git:commit`.
478This 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.
487
487
488### Built-in Commands
488### Built-in Commands
489
489
490−OpenCode includes several built-in commands:
490+Crush includes several built-in commands:
491
491
492| Command | Description |
492| Command | Description |
493| ------------------ | --------------------------------------------------------------------------------------------------- |
493| ------------------ | --------------------------------------------------------------------------------------------------- |
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 |
495| Compact Session | Manually triggers the summarization of the current session, creating a new session with the summary |
495| Compact Session | Manually triggers the summarization of the current session, creating a new session with the summary |
496
496
497## MCP (Model Context Protocol)
497## MCP (Model Context Protocol)
498
498
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.
500
500
501### MCP Features
501### MCP Features
502
502
@@ -537,7 +537,7 @@ Once configured, MCP tools are automatically available to the AI assistant along
537
537
538## LSP (Language Server Protocol)
538## LSP (Language Server Protocol)
539
539
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.
541
541
542### LSP Features
542### LSP Features
543
543
@@ -576,13 +576,13 @@ While the LSP client implementation supports the full LSP protocol (including co
576
576
577## Using a self-hosted model provider
577## Using a self-hosted model provider
578
578
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.
580This is useful for developers who want to experiment with custom models.
580This is useful for developers who want to experiment with custom models.
581
581
582### Configuring a self-hosted provider
582### Configuring a self-hosted provider
583
583
584You can use a self-hosted model by setting the `LOCAL_ENDPOINT` environment variable.
584You 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.
586
586
587```bash
587```bash
588LOCAL_ENDPOINT=http://localhost:1235/v1
588LOCAL_ENDPOINT=http://localhost:1235/v1
@@ -613,19 +613,19 @@ You can also configure a self-hosted model in the configuration file under the `
613
613
614```bash
614```bash
615# Clone the repository
615# Clone the repository
616−git clone https://github.com/opencode-ai/opencode.git
616+git clone https://github.com/charmbracelet/crush.git
617−cd opencode
617+cd crush
618
618
619# Build
619# Build
620−go build -o opencode
620+go build -o crush
621
621
622# Run
622# Run
623−./opencode
623+./crush
624```
624```
625
625
626## Acknowledgments
626## Acknowledgments
627
627
628−OpenCode gratefully acknowledges the contributions and support from these key individuals:
628+Crush gratefully acknowledges the contributions and support from these key individuals:
629
629
630- [@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
630- [@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
631- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
631- [@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
634
634
635## License
635## License
636
636
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.
638
638
639## Contributing
639## Contributing
640
640
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 (
7 "sync"
7 "sync"
8 "time"
8 "time"
9
9
10− tea "github.com/charmbracelet/bubbletea"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11− zone "github.com/lrstanley/bubblezone"
12− "github.com/opencode-ai/opencode/internal/app"
11+ "github.com/charmbracelet/crush/internal/app"
13− "github.com/opencode-ai/opencode/internal/config"
12+ "github.com/charmbracelet/crush/internal/config"
14− "github.com/opencode-ai/opencode/internal/db"
13+ "github.com/charmbracelet/crush/internal/db"
15− "github.com/opencode-ai/opencode/internal/format"
14+ "github.com/charmbracelet/crush/internal/format"
16− "github.com/opencode-ai/opencode/internal/llm/agent"
15+ "github.com/charmbracelet/crush/internal/llm/agent"
17− "github.com/opencode-ai/opencode/internal/logging"
16+ "github.com/charmbracelet/crush/internal/logging"
18− "github.com/opencode-ai/opencode/internal/pubsub"
17+ "github.com/charmbracelet/crush/internal/pubsub"
19− "github.com/opencode-ai/opencode/internal/tui"
18+ "github.com/charmbracelet/crush/internal/tui"
20− "github.com/opencode-ai/opencode/internal/version"
19+ "github.com/charmbracelet/crush/internal/version"
21 "github.com/spf13/cobra"
20 "github.com/spf13/cobra"
22)
21)
23
22
24var rootCmd = &cobra.Command{
23var rootCmd = &cobra.Command{
25− Use: "opencode",
24+ Use: "crush",
26 Short: "Terminal-based AI assistant for software development",
25 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.
28It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
27It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
29to assist developers in writing, debugging, and understanding code directly from the terminal.`,
28to assist developers in writing, debugging, and understanding code directly from the terminal.`,
30 Example: `
29 Example: `
31 # Run in interactive mode
30 # Run in interactive mode
32− opencode
31+ crush
33
32
34 # Run with debug logging
33 # Run with debug logging
35− opencode -d
34+ crush -d
36
35
37 # Run with debug logging in a specific directory
36 # Run with debug logging in a specific directory
38− opencode -d -c /path/to/project
37+ crush -d -c /path/to/project
39
38
40 # Print version
39 # Print version
41− opencode -v
40+ crush -v
42
41
43 # Run a single non-interactive prompt
42 # 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"
45
44
46 # Run a single non-interactive prompt with JSON output format
45 # 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
48 `,
47 `,
49 RunE: func(cmd *cobra.Command, args []string) error {
48 RunE: func(cmd *cobra.Command, args []string) error {
50 // If the help flag is set, show the help message
49 // 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
114 return app.RunNonInteractive(ctx, prompt, outputFormat, quiet)
113 return app.RunNonInteractive(ctx, prompt, outputFormat, quiet)
115 }
114 }
116
115
117− // Interactive mode
118 // Set up the TUI
116 // Set up the TUI
119− zone.NewGlobal()
120 program := tea.NewProgram(
117 program := tea.NewProgram(
121 tui.New(app),
118 tui.New(app),
122 tea.WithAltScreen(),
119 tea.WithAltScreen(),
cmd/schema/README.md+5−5
@@ -1,11 +1,11 @@
1−# OpenCode Configuration Schema Generator
1+# Crush Configuration Schema Generator
2
2
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.
4
4
5## Usage
5## Usage
6
6
7```bash
7```bash
8−go run cmd/schema/main.go > opencode-schema.json
8+go run cmd/schema/main.go > crush-schema.json
9```
9```
10
10
11This will generate a JSON Schema file that can be used to validate configuration files.
11This will generate a JSON Schema file that can be used to validate configuration files.
@@ -24,7 +24,7 @@ The generated schema includes:
24
24
25You can use the generated schema in several ways:
25You can use the generated schema in several ways:
26
26
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.
28
28
292. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema.
292. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema.
30
30
@@ -37,7 +37,7 @@ Here's an example configuration that conforms to the schema:
37```json
37```json
38{
38{
39 "data": {
39 "data": {
40− "directory": ".opencode"
40+ "directory": ".crush"
41 },
41 },
42 "debug": false,
42 "debug": false,
43 "providers": {
43 "providers": {
cmd/schema/main.go+13−13
@@ -5,8 +5,8 @@ import (
5 "fmt"
5 "fmt"
6 "os"
6 "os"
7
7
8− "github.com/opencode-ai/opencode/internal/config"
8+ "github.com/charmbracelet/crush/internal/config"
9− "github.com/opencode-ai/opencode/internal/llm/models"
9+ "github.com/charmbracelet/crush/internal/llm/models"
10)
10)
11
11
12// JSONSchemaType represents a JSON Schema type
12// JSONSchemaType represents a JSON Schema type
@@ -38,8 +38,8 @@ func main() {
38func generateSchema() map[string]any {
38func generateSchema() map[string]any {
39 schema := map[string]any{
39 schema := map[string]any{
40 "$schema": "http://json-schema.org/draft-07/schema#",
40 "$schema": "http://json-schema.org/draft-07/schema#",
41− "title": "OpenCode Configuration",
41+ "title": "Crush Configuration",
42− "description": "Configuration schema for the OpenCode application",
42+ "description": "Configuration schema for the Crush application",
43 "type": "object",
43 "type": "object",
44 "properties": map[string]any{},
44 "properties": map[string]any{},
45 }
45 }
@@ -52,7 +52,7 @@ func generateSchema() map[string]any {
52 "directory": map[string]any{
52 "directory": map[string]any{
53 "type": "string",
53 "type": "string",
54 "description": "Directory where application data is stored",
54 "description": "Directory where application data is stored",
55− "default": ".opencode",
55+ "default": ".crush",
56 },
56 },
57 },
57 },
58 "required": []string{"directory"},
58 "required": []string{"directory"},
@@ -89,12 +89,12 @@ func generateSchema() map[string]any {
89 ".cursor/rules/",
89 ".cursor/rules/",
90 "CLAUDE.md",
90 "CLAUDE.md",
91 "CLAUDE.local.md",
91 "CLAUDE.local.md",
92− "opencode.md",
92+ "crush.md",
93− "opencode.local.md",
93+ "crush.local.md",
94− "OpenCode.md",
94+ "Crush.md",
95− "OpenCode.local.md",
95+ "Crush.local.md",
96− "OPENCODE.md",
96+ "CRUSH.md",
97− "OPENCODE.local.md",
97+ "CRUSH.local.md",
98 },
98 },
99 }
99 }
100
100
@@ -105,9 +105,9 @@ func generateSchema() map[string]any {
105 "theme": map[string]any{
105 "theme": map[string]any{
106 "type": "string",
106 "type": "string",
107 "description": "TUI theme name",
107 "description": "TUI theme name",
108− "default": "opencode",
108+ "default": "crush",
109 "enum": []string{
109 "enum": []string{
110− "opencode",
110+ "crush",
111 "catppuccin",
111 "catppuccin",
112 "dracula",
112 "dracula",
113 "flexoki",
113 "flexoki",
opencode-schema.json → crush-schema.jsonrenamed+11−11
@@ -97,7 +97,7 @@
97 "type": "object"
97 "type": "object"
98 }
98 }
99 },
99 },
100− "description": "Configuration schema for the OpenCode application",
100+ "description": "Configuration schema for the Crush application",
101 "properties": {
101 "properties": {
102 "agents": {
102 "agents": {
103 "additionalProperties": {
103 "additionalProperties": {
@@ -216,12 +216,12 @@
216 ".cursor/rules/",
216 ".cursor/rules/",
217 "CLAUDE.md",
217 "CLAUDE.md",
218 "CLAUDE.local.md",
218 "CLAUDE.local.md",
219− "opencode.md",
219+ "crush.md",
220− "opencode.local.md",
220+ "crush.local.md",
221− "OpenCode.md",
221+ "Crush.md",
222− "OpenCode.local.md",
222+ "Crush.local.md",
223− "OPENCODE.md",
223+ "CRUSH.md",
224− "OPENCODE.local.md"
224+ "CRUSH.local.md"
225 ],
225 ],
226 "description": "Context paths for the application",
226 "description": "Context paths for the application",
227 "items": {
227 "items": {
@@ -233,7 +233,7 @@
233 "description": "Storage configuration",
233 "description": "Storage configuration",
234 "properties": {
234 "properties": {
235 "directory": {
235 "directory": {
236− "default": ".opencode",
236+ "default": ".crush",
237 "description": "Directory where application data is stored",
237 "description": "Directory where application data is stored",
238 "type": "string"
238 "type": "string"
239 }
239 }
@@ -374,10 +374,10 @@
374 "description": "Terminal User Interface configuration",
374 "description": "Terminal User Interface configuration",
375 "properties": {
375 "properties": {
376 "theme": {
376 "theme": {
377− "default": "opencode",
377+ "default": "crush",
378 "description": "TUI theme name",
378 "description": "TUI theme name",
379 "enum": [
379 "enum": [
380− "opencode",
380+ "crush",
381 "catppuccin",
381 "catppuccin",
382 "dracula",
382 "dracula",
383 "flexoki",
383 "flexoki",
@@ -397,6 +397,6 @@
397 "type": "string"
397 "type": "string"
398 }
398 }
399 },
399 },
400− "title": "OpenCode Configuration",
400+ "title": "Crush Configuration",
401 "type": "object"
401 "type": "object"
402}
402}
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+29−24
@@ -1,42 +1,51 @@
1−module github.com/opencode-ai/opencode
1+module github.com/charmbracelet/crush
2
2
3go 1.24.0
3go 1.24.0
4
4
5require (
5require (
6 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
6 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
7 github.com/JohannesKaufmann/html-to-markdown v1.6.0
7 github.com/JohannesKaufmann/html-to-markdown v1.6.0
8+ github.com/MakeNowJust/heredoc v1.0.0
8 github.com/PuerkitoBio/goquery v1.9.2
9 github.com/PuerkitoBio/goquery v1.9.2
9 github.com/alecthomas/chroma/v2 v2.15.0
10 github.com/alecthomas/chroma/v2 v2.15.0
10 github.com/anthropics/anthropic-sdk-go v1.4.0
11 github.com/anthropics/anthropic-sdk-go v1.4.0
11 github.com/aymanbagabas/go-udiff v0.3.1
12 github.com/aymanbagabas/go-udiff v0.3.1
12 github.com/bmatcuk/doublestar/v4 v4.8.1
13 github.com/bmatcuk/doublestar/v4 v4.8.1
13− github.com/catppuccin/go v0.3.0
14+ github.com/charlievieth/fastwalk v1.0.11
14− github.com/charmbracelet/bubbles v0.21.0
15+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e
15− github.com/charmbracelet/bubbletea v1.3.5
16+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94
16− github.com/charmbracelet/glamour v0.9.1
17+ github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
17− github.com/charmbracelet/lipgloss v1.1.0
18+ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
18− github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1
19+ github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
19− github.com/charmbracelet/x/ansi v0.8.0
20− github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444
20+ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
21− github.com/charmbracelet/x/exp/golden v0.0.0-20250602192518-9e722df69bbb
21+ github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
22− github.com/charmbracelet/x/exp/slice v0.0.0-20250606192012-6931dbeeced5
22+ github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
23 github.com/fsnotify/fsnotify v1.8.0
23 github.com/fsnotify/fsnotify v1.8.0
24 github.com/go-logfmt/logfmt v0.6.0
24 github.com/go-logfmt/logfmt v0.6.0
25 github.com/google/uuid v1.6.0
25 github.com/google/uuid v1.6.0
26− github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
27 github.com/mark3labs/mcp-go v0.17.0
26 github.com/mark3labs/mcp-go v0.17.0
28− github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
29− github.com/muesli/reflow v0.3.0
30 github.com/muesli/termenv v0.16.0
27 github.com/muesli/termenv v0.16.0
31 github.com/ncruces/go-sqlite3 v0.25.0
28 github.com/ncruces/go-sqlite3 v0.25.0
29+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
32 github.com/openai/openai-go v0.1.0-beta.2
30 github.com/openai/openai-go v0.1.0-beta.2
33 github.com/pressly/goose/v3 v3.24.2
31 github.com/pressly/goose/v3 v3.24.2
32+ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
33+ github.com/sahilm/fuzzy v0.1.1
34 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
34 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
35 github.com/spf13/cobra v1.9.1
35 github.com/spf13/cobra v1.9.1
36 github.com/spf13/viper v1.20.0
36 github.com/spf13/viper v1.20.0
37+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
38+ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
37 github.com/stretchr/testify v1.10.0
39 github.com/stretchr/testify v1.10.0
38)
40)
39
41
42+require (
43+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
44+ github.com/disintegration/gift v1.1.2 // indirect
45+ github.com/dustin/go-humanize v1.0.1 // indirect
46+ github.com/mattn/go-isatty v0.0.20 // indirect
47+)
48+
40require (
49require (
41 cloud.google.com/go v0.116.0 // indirect
50 cloud.google.com/go v0.116.0 // indirect
42 cloud.google.com/go/auth v0.13.0 // indirect
51 cloud.google.com/go/auth v0.13.0 // indirect
@@ -60,15 +69,15 @@ require (
60 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
69 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
61 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
70 github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
62 github.com/aws/smithy-go v1.20.3 // indirect
71 github.com/aws/smithy-go v1.20.3 // indirect
63− github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
64 github.com/aymerick/douceur v0.2.0 // indirect
72 github.com/aymerick/douceur v0.2.0 // indirect
65− github.com/charmbracelet/colorprofile v0.3.0 // indirect
73+ github.com/charmbracelet/colorprofile v0.3.1 // indirect
66− github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
74+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
75+ github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
76+ github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect
67 github.com/charmbracelet/x/term v0.2.1 // indirect
77 github.com/charmbracelet/x/term v0.2.1 // indirect
78+ github.com/charmbracelet/x/windows v0.2.1 // indirect
68 github.com/davecgh/go-spew v1.1.1 // indirect
79 github.com/davecgh/go-spew v1.1.1 // indirect
69− github.com/disintegration/imaging v1.6.2
70 github.com/dlclark/regexp2 v1.11.4 // indirect
80 github.com/dlclark/regexp2 v1.11.4 // indirect
71− github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
72 github.com/felixge/httpsnoop v1.0.4 // indirect
81 github.com/felixge/httpsnoop v1.0.4 // indirect
73 github.com/go-logr/logr v1.4.2 // indirect
82 github.com/go-logr/logr v1.4.2 // indirect
74 github.com/go-logr/stdr v1.2.2 // indirect
83 github.com/go-logr/stdr v1.2.2 // indirect
@@ -82,10 +91,7 @@ require (
82 github.com/gorilla/websocket v1.5.3 // indirect
91 github.com/gorilla/websocket v1.5.3 // indirect
83 github.com/inconshreveable/mousetrap v1.1.0 // indirect
92 github.com/inconshreveable/mousetrap v1.1.0 // indirect
84 github.com/kylelemons/godebug v1.1.0 // indirect
93 github.com/kylelemons/godebug v1.1.0 // indirect
85− github.com/lithammer/fuzzysearch v1.1.8
86 github.com/lucasb-eyer/go-colorful v1.2.0
94 github.com/lucasb-eyer/go-colorful v1.2.0
87− github.com/mattn/go-isatty v0.0.20 // indirect
88− github.com/mattn/go-localereader v0.0.1 // indirect
89 github.com/mattn/go-runewidth v0.0.16 // indirect
95 github.com/mattn/go-runewidth v0.0.16 // indirect
90 github.com/mfridman/interpolate v0.0.2 // indirect
96 github.com/mfridman/interpolate v0.0.2 // indirect
91 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
97 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
@@ -94,7 +100,7 @@ require (
94 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
100 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
95 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
101 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
96 github.com/pmezard/go-difflib v1.0.0 // indirect
102 github.com/pmezard/go-difflib v1.0.0 // indirect
97− github.com/rivo/uniseg v0.4.7 // indirect
103+ github.com/rivo/uniseg v0.4.7
98 github.com/rogpeppe/go-internal v1.14.1 // indirect
104 github.com/rogpeppe/go-internal v1.14.1 // indirect
99 github.com/sagikazarmark/locafero v0.7.0 // indirect
105 github.com/sagikazarmark/locafero v0.7.0 // indirect
100 github.com/sethvargo/go-retry v0.3.0 // indirect
106 github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -123,7 +129,6 @@ require (
123 golang.org/x/net v0.39.0 // indirect
129 golang.org/x/net v0.39.0 // indirect
124 golang.org/x/sync v0.13.0 // indirect
130 golang.org/x/sync v0.13.0 // indirect
125 golang.org/x/sys v0.32.0 // indirect
131 golang.org/x/sys v0.32.0 // indirect
126− golang.org/x/term v0.31.0 // indirect
127 golang.org/x/text v0.24.0 // indirect
132 golang.org/x/text v0.24.0 // indirect
128 google.golang.org/genai v1.3.0
133 google.golang.org/genai v1.3.0
129 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
134 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
go.sum+42−44
@@ -66,44 +66,46 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
66github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
66github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
67github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
67github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
68github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
68github.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=
69+github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
70−github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
70+github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
71−github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
71+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
72−github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
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 v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
73+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94 h1:QIi50k+uNTJmp2sMs+33D1m/EWr/7OPTJ8x92AY3eOc=
74−github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
74+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
75−github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
75+github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
76−github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
76+github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
77−github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
77+github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
78−github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
78+github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
79−github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
79+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
80−github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
81−github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
82−github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
80+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
83−github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
81+github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ=
84−github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
82+github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
85−github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
83+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
86−github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
84+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-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
85+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY=
88−github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
86+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-20250602192518-9e722df69bbb h1:GT/STWThMsrOfYQnhnIPb165e/g1waAp0gNMFvEO6WI=
87+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-20250602192518-9e722df69bbb/go.mod h1:929X+xY3LeoOZrDWIBVZcx/zyS0CYtyLiUIvE4VbKC0=
88+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-20250606192012-6931dbeeced5 h1:NrUNqSKeW0nKSccnzgJYxC+SFGx+C7wskfASBKToXBs=
89+github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef h1:v7qwsZ2OxzlwvpKwz8dtZXp7fIJlcDEUOyFBNE4fz4Q=
92−github.com/charmbracelet/x/exp/slice v0.0.0-20250606192012-6931dbeeced5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
90+github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
91+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y=
92+github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA=
93github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
93github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
94github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
94github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
95+github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
96+github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
95github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
97github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
96github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
98github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
97github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
99github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
98github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
100github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
99−github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
101+github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
100−github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
102+github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
103+github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
104+github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
101github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
105github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
102github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
106github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
103github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
107github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
104github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
108github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
105−github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
106−github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
107github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
109github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
108github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
110github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
109github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
111github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -150,31 +152,20 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
150github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
152github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
151github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
153github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
152github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
154github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
153−github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
154−github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
155−github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
156−github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
157github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
155github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
158github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
156github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
159github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
157github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
160github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
158github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
161github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
159github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
162github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
160github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
163−github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
164−github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
165−github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
166github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
161github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
167github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
162github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
168github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
163github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
169github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
164github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
170github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
165github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
171github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
166github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
172−github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
173−github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
174github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
167github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
175github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
168github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
176−github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
177−github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
178github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
169github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
179github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
170github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
180github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
171github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
@@ -183,6 +174,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
183github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
174github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
184github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
175github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
185github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
176github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
177+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
178+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
186github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
179github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
187github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
180github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
188github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
181github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
@@ -196,15 +189,18 @@ github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJz
196github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
189github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
197github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
190github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
198github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
191github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
199−github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
200github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
192github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
201github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
193github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
202github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
194github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
203github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
195github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
204github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
196github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
205github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
197github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
198+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
199+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
206github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
200github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
207github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
201github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
202+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
203+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
208github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
204github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
209github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
205github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
210github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
206github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@@ -225,9 +221,14 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
225github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
221github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
226github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
222github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
227github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
223github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
224+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
225+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
226+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
227+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
228github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
228github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
229github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
229github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
230github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
230github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
231+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
231github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
232github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
232github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
233github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
233github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
234github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -279,7 +280,6 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
279golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
280golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
280golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
281golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
281golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
282golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
282−golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
283golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
283golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
284golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
284golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
285golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
285golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -303,7 +303,6 @@ golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
303golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
303golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
304golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
304golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
305golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
305golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
306−golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
307golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
306golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
308golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
307golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
309golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
308golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -324,8 +323,6 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
324golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
323golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
325golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
324golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
326golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
325golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
327−golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
328−golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
329golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
326golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
330golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
327golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
331golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
328golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -355,6 +352,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
355gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
352gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
356gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
353gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
357gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
354gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
355+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
358gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
356gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
359gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
357gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
360modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
358modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
install+14−14
@@ -1,6 +1,6 @@
1#!/usr/bin/env bash
1#!/usr/bin/env bash
2set -euo pipefail
2set -euo pipefail
3−APP=opencode
3+APP=crush
4
4
5RED='\033[0;31m'
5RED='\033[0;31m'
6GREEN='\033[0;32m'
6GREEN='\033[0;32m'
@@ -36,19 +36,19 @@ case "$filename" in
36 ;;
36 ;;
37esac
37esac
38
38
39−INSTALL_DIR=$HOME/.opencode/bin
39+INSTALL_DIR=$HOME/.crush/bin
40mkdir -p "$INSTALL_DIR"
40mkdir -p "$INSTALL_DIR"
41
41
42if [ -z "$requested_version" ]; then
42if [ -z "$requested_version" ]; then
43− url="https://github.com/opencode-ai/opencode/releases/latest/download/$filename"
43+ url="https://github.com/charmbracelet/crush/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}')
44+ specific_version=$(curl -s https://api.github.com/repos/charmbracelet/crush/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
45
45
46 if [[ $? -ne 0 ]]; then
46 if [[ $? -ne 0 ]]; then
47 echo "${RED}Failed to fetch version information${NC}"
47 echo "${RED}Failed to fetch version information${NC}"
48 exit 1
48 exit 1
49 fi
49 fi
50else
50else
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"
52 specific_version=$requested_version
52 specific_version=$requested_version
53fi
53fi
54
54
@@ -67,12 +67,12 @@ print_message() {
67}
67}
68
68
69check_version() {
69check_version() {
70− if command -v opencode >/dev/null 2>&1; then
70+ if command -v crush >/dev/null 2>&1; then
71− opencode_path=$(which opencode)
71+ crush_path=$(which crush)
72
72
73
73
74 ## TODO: check if version is installed
74 ## TODO: check if version is installed
75− # installed_version=$(opencode version)
75+ # installed_version=$(crush version)
76 installed_version="0.0.1"
76 installed_version="0.0.1"
77 installed_version=$(echo $installed_version | awk '{print $2}')
77 installed_version=$(echo $installed_version | awk '{print $2}')
78
78
@@ -86,11 +86,11 @@ check_version() {
86}
86}
87
87
88download_and_install() {
88download_and_install() {
89− print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
89+ print_message info "Downloading ${ORANGE}crush ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
90− mkdir -p opencodetmp && cd opencodetmp
90+ mkdir -p crushtmp && cd crushtmp
91 curl -# -L $url | tar xz
91 curl -# -L $url | tar xz
92− mv opencode $INSTALL_DIR
92+ mv crush $INSTALL_DIR
93− cd .. && rm -rf opencodetmp
93+ cd .. && rm -rf crushtmp
94}
94}
95
95
96check_version
96check_version
@@ -102,9 +102,9 @@ add_to_path() {
102 local command=$2
102 local command=$2
103
103
104 if [[ -w $config_file ]]; then
104 if [[ -w $config_file ]]; then
105− echo -e "\n# opencode" >> "$config_file"
105+ echo -e "\n# crush" >> "$config_file"
106 echo "$command" >> "$config_file"
106 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"
108 else
108 else
109 print_message warning "Manually add the directory to $config_file (or similar):"
109 print_message warning "Manually add the directory to $config_file (or similar):"
110 print_message info " $command"
110 print_message info " $command"
internal/app/app.go+10−30
@@ -9,17 +9,16 @@ import (
9 "sync"
9 "sync"
10 "time"
10 "time"
11
11
12− "github.com/opencode-ai/opencode/internal/config"
12+ "github.com/charmbracelet/crush/internal/config"
13− "github.com/opencode-ai/opencode/internal/db"
13+ "github.com/charmbracelet/crush/internal/db"
14− "github.com/opencode-ai/opencode/internal/format"
14+ "github.com/charmbracelet/crush/internal/format"
15− "github.com/opencode-ai/opencode/internal/history"
15+ "github.com/charmbracelet/crush/internal/history"
16− "github.com/opencode-ai/opencode/internal/llm/agent"
16+ "github.com/charmbracelet/crush/internal/llm/agent"
17− "github.com/opencode-ai/opencode/internal/logging"
17+ "github.com/charmbracelet/crush/internal/logging"
18− "github.com/opencode-ai/opencode/internal/lsp"
18+ "github.com/charmbracelet/crush/internal/lsp"
19− "github.com/opencode-ai/opencode/internal/message"
19+ "github.com/charmbracelet/crush/internal/message"
20− "github.com/opencode-ai/opencode/internal/permission"
20+ "github.com/charmbracelet/crush/internal/permission"
21− "github.com/opencode-ai/opencode/internal/session"
21+ "github.com/charmbracelet/crush/internal/session"
22− "github.com/opencode-ai/opencode/internal/tui/theme"
23)
22)
24
23
25type App struct {
24type App struct {
@@ -53,9 +52,6 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
53 LSPClients: make(map[string]*lsp.Client),
52 LSPClients: make(map[string]*lsp.Client),
54 }
53 }
55
54
56− // Initialize theme based on configuration
57− app.initTheme()
58−
59 // Initialize LSP clients in the background
55 // Initialize LSP clients in the background
60 go app.initLSPClients(ctx)
56 go app.initLSPClients(ctx)
61
57
@@ -80,22 +76,6 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
80 return app, nil
76 return app, nil
81}
77}
82
78
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−
99// RunNonInteractive handles the execution flow when a prompt is provided via CLI flag.
79// RunNonInteractive handles the execution flow when a prompt is provided via CLI flag.
100func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat string, quiet bool) error {
80func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat string, quiet bool) error {
101 logging.Info("Running in non-interactive mode")
81 logging.Info("Running in non-interactive mode")
internal/app/lsp.go+9−9
@@ -4,10 +4,10 @@ import (
4 "context"
4 "context"
5 "time"
5 "time"
6
6
7− "github.com/opencode-ai/opencode/internal/config"
7+ "github.com/charmbracelet/crush/internal/config"
8− "github.com/opencode-ai/opencode/internal/logging"
8+ "github.com/charmbracelet/crush/internal/logging"
9− "github.com/opencode-ai/opencode/internal/lsp"
9+ "github.com/charmbracelet/crush/internal/lsp"
10− "github.com/opencode-ai/opencode/internal/lsp/watcher"
10+ "github.com/charmbracelet/crush/internal/lsp/watcher"
11)
11)
12
12
13func (app *App) initLSPClients(ctx context.Context) {
13func (app *App) initLSPClients(ctx context.Context) {
@@ -25,7 +25,7 @@ func (app *App) initLSPClients(ctx context.Context) {
25func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
25func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
26 // Create a specific context for initialization with a timeout
26 // Create a specific context for initialization with a timeout
27 logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
27 logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
28−
28+
29 // Create the LSP client
29 // Create the LSP client
30 lspClient, err := lsp.NewClient(ctx, command, args...)
30 lspClient, err := lsp.NewClient(ctx, command, args...)
31 if err != nil {
31 if err != nil {
@@ -36,7 +36,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
36 // Create a longer timeout for initialization (some servers take time to start)
36 // Create a longer timeout for initialization (some servers take time to start)
37 initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
37 initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
38 defer cancel()
38 defer cancel()
39−
39+
40 // Initialize with the initialization context
40 // Initialize with the initialization context
41 _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
41 _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
42 if err != nil {
42 if err != nil {
@@ -57,13 +57,13 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
57 }
57 }
58
58
59 logging.Info("LSP client initialized", "name", name)
59 logging.Info("LSP client initialized", "name", name)
60−
60+
61 // Create a child context that can be canceled when the app is shutting down
61 // Create a child context that can be canceled when the app is shutting down
62 watchCtx, cancelFunc := context.WithCancel(ctx)
62 watchCtx, cancelFunc := context.WithCancel(ctx)
63−
63+
64 // Create a context with the server name for better identification
64 // Create a context with the server name for better identification
65 watchCtx = context.WithValue(watchCtx, "serverName", name)
65 watchCtx = context.WithValue(watchCtx, "serverName", name)
66−
66+
67 // Create the workspace watcher
67 // Create the workspace watcher
68 workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
68 workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
69
69
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 (
9 "path/filepath"
9 "path/filepath"
10 "strings"
10 "strings"
11
11
12− "github.com/opencode-ai/opencode/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/llm/models"
13− "github.com/opencode-ai/opencode/internal/logging"
13+ "github.com/charmbracelet/crush/internal/logging"
14 "github.com/spf13/viper"
14 "github.com/spf13/viper"
15)
15)
16
16
@@ -97,9 +97,9 @@ type Config struct {
97
97
98// Application constants
98// Application constants
99const (
99const (
100− defaultDataDirectory = ".opencode"
100+ defaultDataDirectory = ".crush"
101 defaultLogLevel = "info"
101 defaultLogLevel = "info"
102− appName = "opencode"
102+ appName = "crush"
103
103
104 MaxTokensFallbackDefault = 4096
104 MaxTokensFallbackDefault = 4096
105)
105)
@@ -110,12 +110,12 @@ var defaultContextPaths = []string{
110 ".cursor/rules/",
110 ".cursor/rules/",
111 "CLAUDE.md",
111 "CLAUDE.md",
112 "CLAUDE.local.md",
112 "CLAUDE.local.md",
113− "opencode.md",
113+ "crush.md",
114− "opencode.local.md",
114+ "crush.local.md",
115− "OpenCode.md",
115+ "Crush.md",
116− "OpenCode.local.md",
116+ "Crush.local.md",
117− "OPENCODE.md",
117+ "CRUSH.md",
118− "OPENCODE.local.md",
118+ "CRUSH.local.md",
119}
119}
120
120
121// Global configuration instance
121// Global configuration instance
@@ -159,7 +159,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
159 if cfg.Debug {
159 if cfg.Debug {
160 defaultLevel = slog.LevelDebug
160 defaultLevel = slog.LevelDebug
161 }
161 }
162− if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
162+ if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
163 loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
163 loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
164
164
165 // if file does not exist create it
165 // if file does not exist create it
@@ -221,7 +221,7 @@ func configureViper() {
221func setDefaults(debug bool) {
221func setDefaults(debug bool) {
222 viper.SetDefault("data.directory", defaultDataDirectory)
222 viper.SetDefault("data.directory", defaultDataDirectory)
223 viper.SetDefault("contextPaths", defaultContextPaths)
223 viper.SetDefault("contextPaths", defaultContextPaths)
224− viper.SetDefault("tui.theme", "opencode")
224+ viper.SetDefault("tui.theme", "crush")
225 viper.SetDefault("autoCompact", true)
225 viper.SetDefault("autoCompact", true)
226
226
227 // Set default shell from environment or fallback to /bin/bash
227 // Set default shell from environment or fallback to /bin/bash
internal/config/init.go+32−1
@@ -4,6 +4,7 @@ import (
4 "fmt"
4 "fmt"
5 "os"
5 "os"
6 "path/filepath"
6 "path/filepath"
7+ "strings"
7)
8)
8
9
9const (
10const (
@@ -37,10 +38,41 @@ func ShouldShowInitDialog() (bool, error) {
37 return false, fmt.Errorf("failed to check init flag file: %w", err)
38 return false, fmt.Errorf("failed to check init flag file: %w", err)
38 }
39 }
39
40
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+
40 // File doesn't exist, show the dialog
51 // File doesn't exist, show the dialog
41 return true, nil
52 return true, nil
42}
53}
43
54
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+
44// MarkProjectInitialized marks the current project as initialized
76// MarkProjectInitialized marks the current project as initialized
45func MarkProjectInitialized() error {
77func MarkProjectInitialized() error {
46 if cfg == nil {
78 if cfg == nil {
@@ -58,4 +90,3 @@ func MarkProjectInitialized() error {
58
90
59 return nil
91 return nil
60}
92}
61−
internal/db/connect.go+3−3
@@ -9,8 +9,8 @@ import (
9 _ "github.com/ncruces/go-sqlite3/driver"
9 _ "github.com/ncruces/go-sqlite3/driver"
10 _ "github.com/ncruces/go-sqlite3/embed"
10 _ "github.com/ncruces/go-sqlite3/embed"
11
11
12− "github.com/opencode-ai/opencode/internal/config"
12+ "github.com/charmbracelet/crush/internal/config"
13− "github.com/opencode-ai/opencode/internal/logging"
13+ "github.com/charmbracelet/crush/internal/logging"
14
14
15 "github.com/pressly/goose/v3"
15 "github.com/pressly/goose/v3"
16)
16)
@@ -23,7 +23,7 @@ func Connect() (*sql.DB, error) {
23 if err := os.MkdirAll(dataDir, 0o700); err != nil {
23 if err := os.MkdirAll(dataDir, 0o700); err != nil {
24 return nil, fmt.Errorf("failed to create data directory: %w", err)
24 return nil, fmt.Errorf("failed to create data directory: %w", err)
25 }
25 }
26− dbPath := filepath.Join(dataDir, "opencode.db")
26+ dbPath := filepath.Join(dataDir, "crush.db")
27 // Open the SQLite database
27 // Open the SQLite database
28 db, err := sql.Open("sqlite3", dbPath)
28 db, err := sql.Open("sqlite3", dbPath)
29 if err != nil {
29 if err != nil {
internal/diff/diff.go+36−270
@@ -1,22 +1,18 @@
1package diff
1package diff
2
2
3import (
3import (
4− "bytes"
5 "fmt"
4 "fmt"
6− "io"
5+ "image/color"
7 "regexp"
6 "regexp"
8 "strconv"
7 "strconv"
9 "strings"
8 "strings"
10
9
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"
15 "github.com/aymanbagabas/go-udiff"
10 "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"
17 "github.com/charmbracelet/x/ansi"
15 "github.com/charmbracelet/x/ansi"
18− "github.com/opencode-ai/opencode/internal/config"
19− "github.com/opencode-ai/opencode/internal/tui/theme"
20 "github.com/sergi/go-diff/diffmatchpatch"
16 "github.com/sergi/go-diff/diffmatchpatch"
21)
17)
22
18
@@ -236,10 +232,7 @@ func HighlightIntralineChanges(h *Hunk) {
236
232
237 for i := 0; i < len(h.Lines); i++ {
233 for i := 0; i < len(h.Lines); i++ {
238 // Look for removed line followed by added line
234 // Look for removed line followed by added line
239− if i+1 < len(h.Lines) &&
240− h.Lines[i].Kind == LineRemoved &&
235+ if i+1 < len(h.Lines) && h.Lines[i].Kind == LineRemoved && h.Lines[i+1].Kind == LineAdded {
241− h.Lines[i+1].Kind == LineAdded {
242−
243 oldLine := h.Lines[i]
236 oldLine := h.Lines[i]
244 newLine := h.Lines[i+1]
237 newLine := h.Lines[i+1]
245
238
@@ -321,241 +314,26 @@ func pairLines(lines []DiffLine) []linePair {
321// -------------------------------------------------------------------------
314// -------------------------------------------------------------------------
322// Syntax Highlighting
315// Syntax Highlighting
323// -------------------------------------------------------------------------
316// -------------------------------------------------------------------------
324−
317+func getColor(c color.Color) string {
325−// SyntaxHighlight applies syntax highlighting to text based on file extension
318+ rgba := color.RGBAModel.Convert(c).(color.RGBA)
326−func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
319+ return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
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
540}
320}
541
321
542// highlightLine applies syntax highlighting to a single line
322// highlightLine applies syntax highlighting to a single line
543−func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string {
323+func highlightLine(fileName string, line string, bg color.Color) string {
544− var buf bytes.Buffer
545− err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
324+ highlighted, err := highlight.SyntaxHighlight(line, fileName, bg)
546 if err != nil {
325 if err != nil {
547 return line
326 return line
548 }
327 }
549− return buf.String()
328+ return highlighted
550}
329}
551
330
552// createStyles generates the lipgloss styles needed for rendering diffs
331// createStyles generates the lipgloss styles needed for rendering diffs
553−func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
332+func createStyles(t *styles.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
554− removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
333+ removedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.RemovedBg)
555− addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
334+ addedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.AddedBg)
556− contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
335+ contextLineStyle = lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
557− lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
336+ lineNumberStyle = lipgloss.NewStyle().Foreground(t.S().Diff.LineNumber)
558−
559 return
337 return
560}
338}
561
339
@@ -563,20 +341,8 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
563// Rendering Functions
341// Rendering Functions
564// -------------------------------------------------------------------------
342// -------------------------------------------------------------------------
565
343
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−
578// applyHighlighting applies intra-line highlighting to a piece of text
344// 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 {
580 // Find all ANSI sequences in the content
346 // Find all ANSI sequences in the content
581 ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
347 ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
582 ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
348 ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
@@ -616,7 +382,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
616
382
617 // Get the appropriate color based on terminal background
383 // Get the appropriate color based on terminal background
618 bgColor := lipgloss.Color(getColor(highlightBg))
384 bgColor := lipgloss.Color(getColor(highlightBg))
619− fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
385+ // fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
620
386
621 for i := 0; i < len(content); {
387 for i := 0; i < len(content); {
622 // Check if we're at an ANSI sequence
388 // Check if we're at an ANSI sequence
@@ -653,15 +419,15 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
653 currentStyle := ansiSequences[currentPos]
419 currentStyle := ansiSequences[currentPos]
654
420
655 // Apply foreground and background highlight
421 // Apply foreground and background highlight
656− sb.WriteString("\x1b[38;2;")
422+ // sb.WriteString("\x1b[38;2;")
657− r, g, b, _ := fgColor.RGBA()
423+ // r, g, b, _ := fgColor.RGBA()
658− sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
424+ // sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
659 sb.WriteString("\x1b[48;2;")
425 sb.WriteString("\x1b[48;2;")
660− r, g, b, _ = bgColor.RGBA()
426+ r, g, b, _ := bgColor.RGBA()
661 sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
427 sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
662 sb.WriteString(char)
428 sb.WriteString(char)
663 // Reset foreground and background
429 // Reset foreground and background
664− sb.WriteString("\x1b[39m")
430+ // sb.WriteString("\x1b[39m")
665
431
666 // Reapply the original ANSI sequence
432 // Reapply the original ANSI sequence
667 sb.WriteString(currentStyle)
433 sb.WriteString(currentStyle)
@@ -679,10 +445,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
679
445
680// renderLeftColumn formats the left side of a side-by-side diff
446// renderLeftColumn formats the left side of a side-by-side diff
681func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
447func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
682− t := theme.CurrentTheme()
448+ t := styles.CurrentTheme()
683
449
684 if dl == nil {
450 if dl == nil {
685− contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
451+ contextLineStyle := t.S().Base.Background(t.S().Diff.ContextBg)
686 return contextLineStyle.Width(colWidth).Render("")
452 return contextLineStyle.Width(colWidth).Render("")
687 }
453 }
688
454
@@ -693,9 +459,9 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
693 var bgStyle lipgloss.Style
459 var bgStyle lipgloss.Style
694 switch dl.Kind {
460 switch dl.Kind {
695 case LineRemoved:
461 case LineRemoved:
696− marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
462+ marker = removedLineStyle.Foreground(t.S().Diff.Removed).Render("-")
697 bgStyle = removedLineStyle
463 bgStyle = removedLineStyle
698− lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
464+ lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Removed).Background(t.S().Diff.RemovedLineNumberBg)
699 case LineAdded:
465 case LineAdded:
700 marker = "?"
466 marker = "?"
701 bgStyle = contextLineStyle
467 bgStyle = contextLineStyle
@@ -718,7 +484,7 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
718
484
719 // Apply intra-line highlighting for removed lines
485 // Apply intra-line highlighting for removed lines
720 if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
486 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)
722 }
488 }
723
489
724 // Add a padding space for removed lines
490 // Add a padding space for removed lines
@@ -732,17 +498,17 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
732 ansi.Truncate(
498 ansi.Truncate(
733 lineText,
499 lineText,
734 colWidth,
500 colWidth,
735− lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
501+ lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
736 ),
502 ),
737 )
503 )
738}
504}
739
505
740// renderRightColumn formats the right side of a side-by-side diff
506// renderRightColumn formats the right side of a side-by-side diff
741func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
507func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
742− t := theme.CurrentTheme()
508+ t := styles.CurrentTheme()
743
509
744 if dl == nil {
510 if dl == nil {
745− contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
511+ contextLineStyle := lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
746 return contextLineStyle.Width(colWidth).Render("")
512 return contextLineStyle.Width(colWidth).Render("")
747 }
513 }
748
514
@@ -753,9 +519,9 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
753 var bgStyle lipgloss.Style
519 var bgStyle lipgloss.Style
754 switch dl.Kind {
520 switch dl.Kind {
755 case LineAdded:
521 case LineAdded:
756− marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
522+ marker = addedLineStyle.Foreground(t.S().Diff.Added).Render("+")
757 bgStyle = addedLineStyle
523 bgStyle = addedLineStyle
758− lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
524+ lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Added).Background(t.S().Diff.AddedLineNumberBg)
759 case LineRemoved:
525 case LineRemoved:
760 marker = "?"
526 marker = "?"
761 bgStyle = contextLineStyle
527 bgStyle = contextLineStyle
@@ -778,7 +544,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
778
544
779 // Apply intra-line highlighting for added lines
545 // Apply intra-line highlighting for added lines
780 if dl.Kind == LineAdded && len(dl.Segments) > 0 {
546 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)
782 }
548 }
783
549
784 // Add a padding space for added lines
550 // Add a padding space for added lines
@@ -792,7 +558,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
792 ansi.Truncate(
558 ansi.Truncate(
793 lineText,
559 lineText,
794 colWidth,
560 colWidth,
795− lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
561+ lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
796 ),
562 ),
797 )
563 )
798}
564}
internal/exp/diffview/diffview_test.go+1−1
@@ -7,9 +7,9 @@ import (
7 "testing"
7 "testing"
8
8
9 "github.com/alecthomas/chroma/v2/styles"
9 "github.com/alecthomas/chroma/v2/styles"
10+ "github.com/charmbracelet/crush/internal/exp/diffview"
10 "github.com/charmbracelet/x/ansi"
11 "github.com/charmbracelet/x/ansi"
11 "github.com/charmbracelet/x/exp/golden"
12 "github.com/charmbracelet/x/exp/golden"
12− "github.com/opencode-ai/opencode/internal/exp/diffview"
13)
13)
14
14
15//go:embed testdata/TestDefault.before
15//go:embed testdata/TestDefault.before
internal/fileutil/fileutil.go+82−32
@@ -2,7 +2,6 @@ package fileutil
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5− "io/fs"
6 "os"
5 "os"
7 "os/exec"
6 "os/exec"
8 "path/filepath"
7 "path/filepath"
@@ -11,7 +10,9 @@ import (
11 "time"
10 "time"
12
11
13 "github.com/bmatcuk/doublestar/v4"
12 "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"
15)
16)
16
17
17var (
18var (
@@ -53,21 +54,6 @@ func GetRgCmd(globPattern string) *exec.Cmd {
53 return cmd
54 return cmd
54}
55}
55
56
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−
71type FileInfo struct {
57type FileInfo struct {
72 Path string
58 Path string
73 ModTime time.Time
59 ModTime time.Time
@@ -81,7 +67,7 @@ func SkipHidden(path string) bool {
81 }
67 }
82
68
83 commonIgnoredDirs := map[string]bool{
69 commonIgnoredDirs := map[string]bool{
84− ".opencode": true,
70+ ".crush": true,
85 "node_modules": true,
71 "node_modules": true,
86 "vendor": true,
72 "vendor": true,
87 "dist": true,
73 "dist": true,
@@ -112,37 +98,92 @@ func SkipHidden(path string) bool {
112 return false
98 return false
113}
99}
114
100
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+
115−func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
138+func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
116− fsys := os.DirFS(searchPath)
139+ walker := NewFastGlobWalker(searchPath)
117− relPattern := strings.TrimPrefix(pattern, "/")
118 var matches []FileInfo
140 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+ }
119
151
120− err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
121 if d.IsDir() {
152 if d.IsDir() {
153+ if walker.shouldSkip(path) {
154+ return filepath.SkipDir
155+ }
122 return nil
156 return nil
123 }
157 }
124− if SkipHidden(path) {
158+
159+ if walker.shouldSkip(path) {
125 return nil
160 return nil
126 }
161 }
127− info, err := d.Info()
162+
163+ // Check if path matches the pattern
164+ relPath, err := filepath.Rel(searchPath, path)
128 if err != nil {
165 if err != nil {
166+ relPath = path
167+ }
168+
169+ matched, err := doublestar.Match(pattern, relPath)
170+ if err != nil || !matched {
129 return nil
171 return nil
130 }
172 }
131− absPath := path
173+
132− if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
174+ info, err := d.Info()
133− absPath = filepath.Join(searchPath, absPath)
175+ if err != nil {
134− } else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
176+ return nil
135− absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
136 }
177 }
137
178
138− matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
179+ matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
139 if limit > 0 && len(matches) >= limit*2 {
180 if limit > 0 && len(matches) >= limit*2 {
140− return fs.SkipAll
181+ return filepath.SkipAll
141 }
182 }
142 return nil
183 return nil
143 })
184 })
144 if err != nil {
185 if err != nil {
145− return nil, false, fmt.Errorf("glob walk error: %w", err)
186+ return nil, false, fmt.Errorf("fastwalk error: %w", err)
146 }
187 }
147
188
148 sort.Slice(matches, func(i, j int) bool {
189 sort.Slice(matches, func(i, j int) bool {
@@ -161,3 +202,12 @@ func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool,
161 }
202 }
162 return results, truncated, nil
203 return results, truncated, nil
163}
204}
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 (
5 "fmt"
5 "fmt"
6 "os"
6 "os"
7
7
8− "github.com/charmbracelet/bubbles/spinner"
8+ "github.com/charmbracelet/bubbles/v2/spinner"
9− tea "github.com/charmbracelet/bubbletea"
9+ tea "github.com/charmbracelet/bubbletea/v2"
10)
10)
11
11
12// Spinner wraps the bubbles spinner for non-interactive mode
12// Spinner wraps the bubbles spinner for non-interactive mode
@@ -31,7 +31,7 @@ func (m spinnerModel) Init() tea.Cmd {
31
31
32func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
32func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33 switch msg := msg.(type) {
33 switch msg := msg.(type) {
34− case tea.KeyMsg:
34+ case tea.KeyPressMsg:
35 m.quitting = true
35 m.quitting = true
36 return m, tea.Quit
36 return m, tea.Quit
37 case spinner.TickMsg:
37 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 (
8 "strings"
8 "strings"
9 "time"
9 "time"
10
10
11+ "github.com/charmbracelet/crush/internal/db"
12+ "github.com/charmbracelet/crush/internal/pubsub"
11 "github.com/google/uuid"
13 "github.com/google/uuid"
12− "github.com/opencode-ai/opencode/internal/db"
13− "github.com/opencode-ai/opencode/internal/pubsub"
14)
14)
15
15
16const (
16const (
internal/llm/agent/agent-tool.go+5−5
@@ -5,11 +5,11 @@ import (
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7
7
8− "github.com/opencode-ai/opencode/internal/config"
8+ "github.com/charmbracelet/crush/internal/config"
9− "github.com/opencode-ai/opencode/internal/llm/tools"
9+ "github.com/charmbracelet/crush/internal/llm/tools"
10− "github.com/opencode-ai/opencode/internal/lsp"
10+ "github.com/charmbracelet/crush/internal/lsp"
11− "github.com/opencode-ai/opencode/internal/message"
11+ "github.com/charmbracelet/crush/internal/message"
12− "github.com/opencode-ai/opencode/internal/session"
12+ "github.com/charmbracelet/crush/internal/session"
13)
13)
14
14
15type agentTool struct {
15type agentTool struct {
internal/llm/agent/agent.go+30−30
@@ -8,16 +8,16 @@ import (
8 "sync"
8 "sync"
9 "time"
9 "time"
10
10
11− "github.com/opencode-ai/opencode/internal/config"
11+ "github.com/charmbracelet/crush/internal/config"
12− "github.com/opencode-ai/opencode/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/llm/models"
13− "github.com/opencode-ai/opencode/internal/llm/prompt"
13+ "github.com/charmbracelet/crush/internal/llm/prompt"
14− "github.com/opencode-ai/opencode/internal/llm/provider"
14+ "github.com/charmbracelet/crush/internal/llm/provider"
15− "github.com/opencode-ai/opencode/internal/llm/tools"
15+ "github.com/charmbracelet/crush/internal/llm/tools"
16− "github.com/opencode-ai/opencode/internal/logging"
16+ "github.com/charmbracelet/crush/internal/logging"
17− "github.com/opencode-ai/opencode/internal/message"
17+ "github.com/charmbracelet/crush/internal/message"
18− "github.com/opencode-ai/opencode/internal/permission"
18+ "github.com/charmbracelet/crush/internal/permission"
19− "github.com/opencode-ai/opencode/internal/pubsub"
19+ "github.com/charmbracelet/crush/internal/pubsub"
20− "github.com/opencode-ai/opencode/internal/session"
20+ "github.com/charmbracelet/crush/internal/session"
21)
21)
22
22
23// Common errors
23// Common errors
@@ -443,18 +443,14 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
443 assistantMsg.AppendContent(event.Content)
443 assistantMsg.AppendContent(event.Content)
444 return a.messages.Update(ctx, *assistantMsg)
444 return a.messages.Update(ctx, *assistantMsg)
445 case provider.EventToolUseStart:
445 case provider.EventToolUseStart:
446+ logging.Info("Tool call started", "toolCall", event.ToolCall)
446 assistantMsg.AddToolCall(*event.ToolCall)
447 assistantMsg.AddToolCall(*event.ToolCall)
447 return a.messages.Update(ctx, *assistantMsg)
448 return a.messages.Update(ctx, *assistantMsg)
448− // TODO: see how to handle this
449− // case provider.EventToolUseDelta:
449+ case provider.EventToolUseDelta:
450− // tm := time.Unix(assistantMsg.UpdatedAt, 0)
451− // assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
450+ assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
452− // if time.Since(tm) > 1000*time.Millisecond {
453− // err := a.messages.Update(ctx, *assistantMsg)
451+ return a.messages.Update(ctx, *assistantMsg)
454− // assistantMsg.UpdatedAt = time.Now().Unix()
455− // return err
456− // }
457 case provider.EventToolUseStop:
452 case provider.EventToolUseStop:
453+ logging.Info("Finished tool call", "toolCall", event.ToolCall)
458 assistantMsg.FinishToolCall(event.ToolCall.ID)
454 assistantMsg.FinishToolCall(event.ToolCall.ID)
459 return a.messages.Update(ctx, *assistantMsg)
455 return a.messages.Update(ctx, *assistantMsg)
460 case provider.EventError:
456 case provider.EventError:
@@ -590,22 +586,26 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
590 a.Publish(pubsub.CreatedEvent, event)
586 a.Publish(pubsub.CreatedEvent, event)
591
587
592 // Send the messages to the summarize provider
588 // Send the messages to the summarize provider
593− response, err := a.summarizeProvider.SendMessages(
589+ response := a.summarizeProvider.StreamResponse(
594 summarizeCtx,
590 summarizeCtx,
595 msgsWithPrompt,
591 msgsWithPrompt,
596 make([]tools.BaseTool, 0),
592 make([]tools.BaseTool, 0),
597 )
593 )
594+ var finalResponse *provider.ProviderResponse
595+ for r := range response {
598− if err != nil {
596+ if r.Error != nil {
599− event = AgentEvent{
597+ event = AgentEvent{
600− Type: AgentEventTypeError,
598+ Type: AgentEventTypeError,
601− Error: fmt.Errorf("failed to summarize: %w", err),
599+ Error: fmt.Errorf("failed to summarize: %w", err),
602− Done: true,
600+ Done: true,
601+ }
602+ a.Publish(pubsub.CreatedEvent, event)
603+ return
603 }
604 }
604− a.Publish(pubsub.CreatedEvent, event)
605+ finalResponse = r.Response
605− return
606 }
606 }
607
607
608− summary := strings.TrimSpace(response.Content)
608+ summary := strings.TrimSpace(finalResponse.Content)
609 if summary == "" {
609 if summary == "" {
610 event = AgentEvent{
610 event = AgentEvent{
611 Type: AgentEventTypeError,
611 Type: AgentEventTypeError,
@@ -655,10 +655,10 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
655 return
655 return
656 }
656 }
657 oldSession.SummaryMessageID = msg.ID
657 oldSession.SummaryMessageID = msg.ID
658− oldSession.CompletionTokens = response.Usage.OutputTokens
658+ oldSession.CompletionTokens = finalResponse.Usage.OutputTokens
659 oldSession.PromptTokens = 0
659 oldSession.PromptTokens = 0
660 model := a.summarizeProvider.Model()
660 model := a.summarizeProvider.Model()
661− usage := response.Usage
661+ usage := finalResponse.Usage
662 cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
662 cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
663 model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
663 model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
664 model.CostPer1MIn/1e6*float64(usage.InputTokens) +
664 model.CostPer1MIn/1e6*float64(usage.InputTokens) +
internal/llm/agent/mcp-tools.go+7−7
@@ -5,11 +5,11 @@ import (
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7
7
8− "github.com/opencode-ai/opencode/internal/config"
8+ "github.com/charmbracelet/crush/internal/config"
9− "github.com/opencode-ai/opencode/internal/llm/tools"
9+ "github.com/charmbracelet/crush/internal/llm/tools"
10− "github.com/opencode-ai/opencode/internal/logging"
10+ "github.com/charmbracelet/crush/internal/logging"
11− "github.com/opencode-ai/opencode/internal/permission"
11+ "github.com/charmbracelet/crush/internal/permission"
12− "github.com/opencode-ai/opencode/internal/version"
12+ "github.com/charmbracelet/crush/internal/version"
13
13
14 "github.com/mark3labs/mcp-go/client"
14 "github.com/mark3labs/mcp-go/client"
15 "github.com/mark3labs/mcp-go/mcp"
15 "github.com/mark3labs/mcp-go/mcp"
@@ -46,7 +46,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t
46 initRequest := mcp.InitializeRequest{}
46 initRequest := mcp.InitializeRequest{}
47 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
47 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
48 initRequest.Params.ClientInfo = mcp.Implementation{
48 initRequest.Params.ClientInfo = mcp.Implementation{
49− Name: "OpenCode",
49+ Name: "Crush",
50 Version: version.Version,
50 Version: version.Version,
51 }
51 }
52
52
@@ -140,7 +140,7 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
140 initRequest := mcp.InitializeRequest{}
140 initRequest := mcp.InitializeRequest{}
141 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
141 initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
142 initRequest.Params.ClientInfo = mcp.Implementation{
142 initRequest.Params.ClientInfo = mcp.Implementation{
143− Name: "OpenCode",
143+ Name: "Crush",
144 Version: version.Version,
144 Version: version.Version,
145 }
145 }
146
146
internal/llm/agent/tools.go+6−6
@@ -3,12 +3,12 @@ package agent
3import (
3import (
4 "context"
4 "context"
5
5
6− "github.com/opencode-ai/opencode/internal/history"
6+ "github.com/charmbracelet/crush/internal/history"
7− "github.com/opencode-ai/opencode/internal/llm/tools"
7+ "github.com/charmbracelet/crush/internal/llm/tools"
8− "github.com/opencode-ai/opencode/internal/lsp"
8+ "github.com/charmbracelet/crush/internal/lsp"
9− "github.com/opencode-ai/opencode/internal/message"
9+ "github.com/charmbracelet/crush/internal/message"
10− "github.com/opencode-ai/opencode/internal/permission"
10+ "github.com/charmbracelet/crush/internal/permission"
11− "github.com/opencode-ai/opencode/internal/session"
11+ "github.com/charmbracelet/crush/internal/session"
12)
12)
13
13
14func CoderAgentTools(
14func CoderAgentTools(
internal/llm/models/local.go+5−5
@@ -2,6 +2,7 @@ package models
2
2
3import (
3import (
4 "cmp"
4 "cmp"
5+ "context"
5 "encoding/json"
6 "encoding/json"
6 "net/http"
7 "net/http"
7 "net/url"
8 "net/url"
@@ -10,7 +11,7 @@ import (
10 "strings"
11 "strings"
11 "unicode"
12 "unicode"
12
13
13− "github.com/opencode-ai/opencode/internal/logging"
14+ "github.com/charmbracelet/crush/internal/logging"
14 "github.com/spf13/viper"
15 "github.com/spf13/viper"
15)
16)
16
17
@@ -53,7 +54,6 @@ func init() {
53 loadLocalModels(models)
54 loadLocalModels(models)
54
55
55 viper.SetDefault("providers.local.apiKey", "dummy")
56 viper.SetDefault("providers.local.apiKey", "dummy")
56− ProviderPopularity[ProviderLocal] = 0
57 }
57 }
58}
58}
59
59
@@ -75,7 +75,7 @@ type localModel struct {
75}
75}
76
76
77func listLocalModels(modelsEndpoint string) []localModel {
77func listLocalModels(modelsEndpoint string) []localModel {
78− res, err := http.Get(modelsEndpoint)
78+ res, err := http.NewRequestWithContext(context.Background(), http.MethodGet, modelsEndpoint, nil)
79 if err != nil {
79 if err != nil {
80 logging.Debug("Failed to list local models",
80 logging.Debug("Failed to list local models",
81 "error", err,
81 "error", err,
@@ -84,9 +84,9 @@ func listLocalModels(modelsEndpoint string) []localModel {
84 }
84 }
85 defer res.Body.Close()
85 defer res.Body.Close()
86
86
87− if res.StatusCode != http.StatusOK {
87+ if res.Response.StatusCode != http.StatusOK {
88 logging.Debug("Failed to list local models",
88 logging.Debug("Failed to list local models",
89− "status", res.StatusCode,
89+ "status", res.Response.Status,
90 "endpoint", modelsEndpoint,
90 "endpoint", modelsEndpoint,
91 )
91 )
92 }
92 }
internal/llm/models/models.go+1−37
@@ -34,44 +34,8 @@ const (
34 ProviderMock ModelProvider = "__mock"
34 ProviderMock ModelProvider = "__mock"
35)
35)
36
36
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−
49var SupportedModels = map[ModelID]Model{
37var 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
75 BedrockClaude37Sonnet: {
39 BedrockClaude37Sonnet: {
76 ID: BedrockClaude37Sonnet,
40 ID: BedrockClaude37Sonnet,
77 Name: "Bedrock: Claude 3.7 Sonnet",
41 Name: "Bedrock: Claude 3.7 Sonnet",
internal/llm/prompt/coder.go+11−11
@@ -8,9 +8,9 @@ import (
8 "runtime"
8 "runtime"
9 "time"
9 "time"
10
10
11− "github.com/opencode-ai/opencode/internal/config"
11+ "github.com/charmbracelet/crush/internal/config"
12− "github.com/opencode-ai/opencode/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/llm/models"
13− "github.com/opencode-ai/opencode/internal/llm/tools"
13+ "github.com/charmbracelet/crush/internal/llm/tools"
14)
14)
15
15
16func CoderPrompt(provider models.ModelProvider) string {
16func CoderPrompt(provider models.ModelProvider) string {
@@ -25,7 +25,7 @@ func CoderPrompt(provider models.ModelProvider) string {
25}
25}
26
26
27const baseOpenAICoderPrompt = `
27const 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.
29
29
30You can:
30You can:
31- Receive user prompts, project context, and files.
31- Receive user prompts, project context, and files.
@@ -33,7 +33,7 @@ You can:
33- Apply patches, run commands, and manage user approvals based on policy.
33- Apply patches, run commands, and manage user approvals based on policy.
34- Work inside a sandboxed, git-backed workspace with rollback support.
34- Work inside a sandboxed, git-backed workspace with rollback support.
35- Log telemetry so sessions can be replayed or inspected later.
35- 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"
37
37
38
38
39You 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.
39You 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:
71- Remember the user does not see the full output of tools
71- Remember the user does not see the full output of tools
72`
72`
73
73
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.
75
75
76IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
76IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
77
77
78# Memory
78# 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:
801. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
801. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
812. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
812. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
823. Maintaining useful information about the codebase structure and organization
823. Maintaining useful information about the codebase structure and organization
83
83
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.
85
85
86# Tone and style
86# Tone and style
87You 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).
87You 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
153
153
154# Doing tasks
154# Doing tasks
155The 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:
155The 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.
1572. Implement the solution using all tools available to you
1572. Implement the solution using all tools available to you
1583. 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.
1583. 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.
160
160
161NEVER 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.
161NEVER 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.
162
162
163# Tool usage policy
163# Tool usage policy
164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
164- 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.
166- 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.
166- 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.
167
167
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168You 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 (
7 "strings"
7 "strings"
8 "sync"
8 "sync"
9
9
10− "github.com/opencode-ai/opencode/internal/config"
10+ "github.com/charmbracelet/crush/internal/config"
11− "github.com/opencode-ai/opencode/internal/llm/models"
11+ "github.com/charmbracelet/crush/internal/llm/models"
12− "github.com/opencode-ai/opencode/internal/logging"
12+ "github.com/charmbracelet/crush/internal/logging"
13)
13)
14
14
15func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
15func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
internal/llm/prompt/prompt_test.go+4−4
@@ -6,7 +6,7 @@ import (
6 "path/filepath"
6 "path/filepath"
7 "testing"
7 "testing"
8
8
9− "github.com/opencode-ai/opencode/internal/config"
9+ "github.com/charmbracelet/crush/internal/config"
10 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
11 "github.com/stretchr/testify/require"
12)
12)
@@ -44,13 +44,13 @@ func createTestFiles(t *testing.T, tmpDir string, testFiles []string) {
44 for _, path := range testFiles {
44 for _, path := range testFiles {
45 fullPath := filepath.Join(tmpDir, path)
45 fullPath := filepath.Join(tmpDir, path)
46 if path[len(path)-1] == '/' {
46 if path[len(path)-1] == '/' {
47− err := os.MkdirAll(fullPath, 0755)
47+ err := os.MkdirAll(fullPath, 0o755)
48 require.NoError(t, err)
48 require.NoError(t, err)
49 } else {
49 } else {
50 dir := filepath.Dir(fullPath)
50 dir := filepath.Dir(fullPath)
51− err := os.MkdirAll(dir, 0755)
51+ err := os.MkdirAll(dir, 0o755)
52 require.NoError(t, err)
52 require.NoError(t, err)
53− err = os.WriteFile(fullPath, []byte(path+": test content"), 0644)
53+ err = os.WriteFile(fullPath, []byte(path+": test content"), 0o644)
54 require.NoError(t, err)
54 require.NoError(t, err)
55 }
55 }
56 }
56 }
internal/llm/prompt/summarizer.go+1−1
@@ -1,6 +1,6 @@
1package prompt
1package prompt
2
2
3−import "github.com/opencode-ai/opencode/internal/llm/models"
3+import "github.com/charmbracelet/crush/internal/llm/models"
4
4
5func SummarizerPrompt(_ models.ModelProvider) string {
5func SummarizerPrompt(_ models.ModelProvider) string {
6 return `You are a helpful AI assistant tasked with summarizing conversations.
6 return `You are a helpful AI assistant tasked with summarizing conversations.
internal/llm/prompt/task.go+2−2
@@ -3,11 +3,11 @@ package prompt
3import (
3import (
4 "fmt"
4 "fmt"
5
5
6− "github.com/opencode-ai/opencode/internal/llm/models"
6+ "github.com/charmbracelet/crush/internal/llm/models"
7)
7)
8
8
9func TaskPrompt(_ models.ModelProvider) string {
9func 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.
11Notes:
11Notes:
121. 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...".
121. 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...".
132. When relevant, share file names and code snippets relevant to the query
132. When relevant, share file names and code snippets relevant to the query
internal/llm/prompt/title.go+1−1
@@ -1,6 +1,6 @@
1package prompt
1package prompt
2
2
3−import "github.com/opencode-ai/opencode/internal/llm/models"
3+import "github.com/charmbracelet/crush/internal/llm/models"
4
4
5func TitlePrompt(_ models.ModelProvider) string {
5func TitlePrompt(_ models.ModelProvider) string {
6 return `you will generate a short title based on the first message a user begins a conversation with
6 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 (
12 "github.com/anthropics/anthropic-sdk-go"
12 "github.com/anthropics/anthropic-sdk-go"
13 "github.com/anthropics/anthropic-sdk-go/bedrock"
13 "github.com/anthropics/anthropic-sdk-go/bedrock"
14 "github.com/anthropics/anthropic-sdk-go/option"
14 "github.com/anthropics/anthropic-sdk-go/option"
15− "github.com/opencode-ai/opencode/internal/config"
15+ "github.com/charmbracelet/crush/internal/config"
16− "github.com/opencode-ai/opencode/internal/llm/models"
16+ "github.com/charmbracelet/crush/internal/llm/models"
17− "github.com/opencode-ai/opencode/internal/llm/tools"
17+ "github.com/charmbracelet/crush/internal/llm/tools"
18− "github.com/opencode-ai/opencode/internal/logging"
18+ "github.com/charmbracelet/crush/internal/logging"
19− "github.com/opencode-ai/opencode/internal/message"
19+ "github.com/charmbracelet/crush/internal/message"
20)
20)
21
21
22type anthropicOptions struct {
22type anthropicOptions struct {
@@ -195,7 +195,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
195 }
195 }
196}
196}
197
197
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) {
199 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
199 preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
200 cfg := config.Get()
200 cfg := config.Get()
201 if cfg.Debug {
201 if cfg.Debug {
@@ -305,7 +305,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
305 ToolCall: &message.ToolCall{
305 ToolCall: &message.ToolCall{
306 ID: currentToolCallID,
306 ID: currentToolCallID,
307 Finished: false,
307 Finished: false,
308− Input: event.Delta.JSON.PartialJSON.Raw(),
308+ Input: event.Delta.PartialJSON,
309 },
309 },
310 }
310 }
311 }
311 }
@@ -339,6 +339,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
339 Usage: a.usage(accumulatedMessage),
339 Usage: a.usage(accumulatedMessage),
340 FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
340 FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
341 },
341 },
342+ Content: content,
342 }
343 }
343 }
344 }
344 }
345 }
internal/llm/provider/azure.go−1
@@ -16,7 +16,6 @@ type azureClient struct {
16type AzureClient ProviderClient
16type AzureClient ProviderClient
17
17
18func newAzureClient(opts providerClientOptions) AzureClient {
18func newAzureClient(opts providerClientOptions) AzureClient {
19−
20 endpoint := os.Getenv("AZURE_OPENAI_ENDPOINT") // ex: https://foo.openai.azure.com
19 endpoint := os.Getenv("AZURE_OPENAI_ENDPOINT") // ex: https://foo.openai.azure.com
21 apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") // ex: 2025-04-01-preview
20 apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") // ex: 2025-04-01-preview
22
21
internal/llm/provider/bedrock.go+2−3
@@ -7,8 +7,8 @@ import (
7 "os"
7 "os"
8 "strings"
8 "strings"
9
9
10− "github.com/opencode-ai/opencode/internal/llm/tools"
10+ "github.com/charmbracelet/crush/internal/llm/tools"
11− "github.com/opencode-ai/opencode/internal/message"
11+ "github.com/charmbracelet/crush/internal/message"
12)
12)
13
13
14type bedrockOptions struct {
14type bedrockOptions struct {
@@ -98,4 +98,3 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
98
98
99 return b.childProvider.stream(ctx, messages, tools)
99 return b.childProvider.stream(ctx, messages, tools)
100}
100}
101−
internal/llm/provider/gemini.go+4−6
@@ -9,11 +9,11 @@ import (
9 "strings"
9 "strings"
10 "time"
10 "time"
11
11
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"
12 "github.com/google/uuid"
16 "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"
17 "google.golang.org/genai"
17 "google.golang.org/genai"
18)
18)
19
19
@@ -365,7 +365,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
365 eventChan <- ProviderEvent{Type: EventContentStop}
365 eventChan <- ProviderEvent{Type: EventContentStop}
366
366
367 if finalResp != nil {
367 if finalResp != nil {
368−
369 finishReason := message.FinishReasonEndTurn
368 finishReason := message.FinishReasonEndTurn
370 if len(finalResp.Candidates) > 0 {
369 if len(finalResp.Candidates) > 0 {
371 finishReason = g.finishReason(finalResp.Candidates[0].FinishReason)
370 finishReason = g.finishReason(finalResp.Candidates[0].FinishReason)
@@ -384,7 +383,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
384 }
383 }
385 return
384 return
386 }
385 }
387−
388 }
386 }
389 }()
387 }()
390
388
internal/llm/provider/openai.go+5−5
@@ -8,14 +8,14 @@ import (
8 "io"
8 "io"
9 "time"
9 "time"
10
10
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"
11 "github.com/openai/openai-go"
16 "github.com/openai/openai-go"
12 "github.com/openai/openai-go/option"
17 "github.com/openai/openai-go/option"
13 "github.com/openai/openai-go/shared"
18 "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"
19)
19)
20
20
21type openaiOptions struct {
21type openaiOptions struct {
internal/llm/provider/provider.go+5−5
@@ -5,9 +5,9 @@ import (
5 "fmt"
5 "fmt"
6 "os"
6 "os"
7
7
8− "github.com/opencode-ai/opencode/internal/llm/models"
8+ "github.com/charmbracelet/crush/internal/llm/models"
9− "github.com/opencode-ai/opencode/internal/llm/tools"
9+ "github.com/charmbracelet/crush/internal/llm/tools"
10− "github.com/opencode-ai/opencode/internal/message"
10+ "github.com/charmbracelet/crush/internal/message"
11)
11)
12
12
13type EventType string
13type EventType string
@@ -130,8 +130,8 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
130 clientOptions.openaiOptions = append(clientOptions.openaiOptions,
130 clientOptions.openaiOptions = append(clientOptions.openaiOptions,
131 WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
131 WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
132 WithOpenAIExtraHeaders(map[string]string{
132 WithOpenAIExtraHeaders(map[string]string{
133− "HTTP-Referer": "opencode.ai",
133+ "HTTP-Referer": "crush.ai",
134− "X-Title": "OpenCode",
134+ "X-Title": "Crush",
135 }),
135 }),
136 )
136 )
137 return &baseProvider[OpenAIClient]{
137 return &baseProvider[OpenAIClient]{
internal/llm/provider/vertexai.go+1−1
@@ -4,7 +4,7 @@ import (
4 "context"
4 "context"
5 "os"
5 "os"
6
6
7− "github.com/opencode-ai/opencode/internal/logging"
7+ "github.com/charmbracelet/crush/internal/logging"
8 "google.golang.org/genai"
8 "google.golang.org/genai"
9)
9)
10
10
internal/llm/tools/bash.go+8−8
@@ -7,9 +7,9 @@ import (
7 "strings"
7 "strings"
8 "time"
8 "time"
9
9
10− "github.com/opencode-ai/opencode/internal/config"
10+ "github.com/charmbracelet/crush/internal/config"
11− "github.com/opencode-ai/opencode/internal/llm/tools/shell"
11+ "github.com/charmbracelet/crush/internal/llm/tools/shell"
12− "github.com/opencode-ai/opencode/internal/permission"
12+ "github.com/charmbracelet/crush/internal/permission"
13)
13)
14
14
15type BashParams struct {
15type BashParams struct {
@@ -122,16 +122,16 @@ When the user asks you to create a new git commit, follow these steps carefully:
122</commit_analysis>
122</commit_analysis>
123
123
1244. Create the commit with a message ending with:
1244. Create the commit with a message ending with:
125−🤖 Generated with opencode
125+🤖 Generated with crush
126−Co-Authored-By: opencode <noreply@opencode.ai>
126+Co-Authored-By: crush <noreply@crush.ai>
127
127
128- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
128- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
129<example>
129<example>
130git commit -m "$(cat <<'EOF'
130git commit -m "$(cat <<'EOF'
131 Commit message here.
131 Commit message here.
132
132
133− 🤖 Generated with opencode
133+ 🤖 Generated with crush
134− Co-Authored-By: opencode <noreply@opencode.ai>
134+ Co-Authored-By: crush <noreply@crush.ai>
135 EOF
135 EOF
136 )"
136 )"
137</example>
137</example>
@@ -193,7 +193,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
193## Test plan
193## Test plan
194[Checklist of TODOs for testing the pull request...]
194[Checklist of TODOs for testing the pull request...]
195
195
196−🤖 Generated with opencode
196+🤖 Generated with crush
197EOF
197EOF
198)"
198)"
199</example>
199</example>
internal/llm/tools/diagnostics.go+2−2
@@ -9,8 +9,8 @@ import (
9 "strings"
9 "strings"
10 "time"
10 "time"
11
11
12− "github.com/opencode-ai/opencode/internal/lsp"
12+ "github.com/charmbracelet/crush/internal/lsp"
13− "github.com/opencode-ai/opencode/internal/lsp/protocol"
13+ "github.com/charmbracelet/crush/internal/lsp/protocol"
14)
14)
15
15
16type DiagnosticsParams struct {
16type DiagnosticsParams struct {
internal/llm/tools/edit.go+6−6
@@ -9,12 +9,12 @@ import (
9 "strings"
9 "strings"
10 "time"
10 "time"
11
11
12− "github.com/opencode-ai/opencode/internal/config"
12+ "github.com/charmbracelet/crush/internal/config"
13− "github.com/opencode-ai/opencode/internal/diff"
13+ "github.com/charmbracelet/crush/internal/diff"
14− "github.com/opencode-ai/opencode/internal/history"
14+ "github.com/charmbracelet/crush/internal/history"
15− "github.com/opencode-ai/opencode/internal/logging"
15+ "github.com/charmbracelet/crush/internal/logging"
16− "github.com/opencode-ai/opencode/internal/lsp"
16+ "github.com/charmbracelet/crush/internal/lsp"
17− "github.com/opencode-ai/opencode/internal/permission"
17+ "github.com/charmbracelet/crush/internal/permission"
18)
18)
19
19
20type EditParams struct {
20type EditParams struct {
internal/llm/tools/fetch.go+3−3
@@ -11,8 +11,8 @@ import (
11
11
12 md "github.com/JohannesKaufmann/html-to-markdown"
12 md "github.com/JohannesKaufmann/html-to-markdown"
13 "github.com/PuerkitoBio/goquery"
13 "github.com/PuerkitoBio/goquery"
14− "github.com/opencode-ai/opencode/internal/config"
14+ "github.com/charmbracelet/crush/internal/config"
15− "github.com/opencode-ai/opencode/internal/permission"
15+ "github.com/charmbracelet/crush/internal/permission"
16)
16)
17
17
18type FetchParams struct {
18type FetchParams struct {
@@ -152,7 +152,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
152 return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
152 return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
153 }
153 }
154
154
155− req.Header.Set("User-Agent", "opencode/1.0")
155+ req.Header.Set("User-Agent", "crush/1.0")
156
156
157 resp, err := client.Do(req)
157 resp, err := client.Do(req)
158 if err != nil {
158 if err != nil {
internal/llm/tools/glob.go+4−4
@@ -10,9 +10,9 @@ import (
10 "sort"
10 "sort"
11 "strings"
11 "strings"
12
12
13− "github.com/opencode-ai/opencode/internal/config"
13+ "github.com/charmbracelet/crush/internal/config"
14− "github.com/opencode-ai/opencode/internal/fileutil"
14+ "github.com/charmbracelet/crush/internal/fileutil"
15− "github.com/opencode-ai/opencode/internal/logging"
15+ "github.com/charmbracelet/crush/internal/logging"
16)
16)
17
17
18const (
18const (
@@ -137,7 +137,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
137 logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
137 logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
138 }
138 }
139
139
140− return fileutil.GlobWithDoublestar(pattern, searchPath, limit)
140+ return fileutil.GlobWithDoubleStar(pattern, searchPath, limit)
141}
141}
142
142
143func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
143func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
internal/llm/tools/grep.go+2−2
@@ -14,8 +14,8 @@ import (
14 "strings"
14 "strings"
15 "time"
15 "time"
16
16
17− "github.com/opencode-ai/opencode/internal/config"
17+ "github.com/charmbracelet/crush/internal/config"
18− "github.com/opencode-ai/opencode/internal/fileutil"
18+ "github.com/charmbracelet/crush/internal/fileutil"
19)
19)
20
20
21type GrepParams struct {
21type GrepParams struct {
internal/llm/tools/ls.go+3−97
@@ -8,7 +8,8 @@ import (
8 "path/filepath"
8 "path/filepath"
9 "strings"
9 "strings"
10
10
11− "github.com/opencode-ai/opencode/internal/config"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/fileutil"
12)
13)
13
14
14type LSParams struct {
15type LSParams struct {
@@ -107,7 +108,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
107 return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
108 return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
108 }
109 }
109
110
110− files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
111+ files, truncated, err := fileutil.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
111 if err != nil {
112 if err != nil {
112 return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
113 return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
113 }
114 }
@@ -128,101 +129,6 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
128 ), nil
129 ), nil
129}
130}
130
131
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−
226func createFileTree(sortedPaths []string) []*TreeNode {
132func createFileTree(sortedPaths []string) []*TreeNode {
227 root := []*TreeNode{}
133 root := []*TreeNode{}
228 pathMap := make(map[string]*TreeNode)
134 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 (
8 "path/filepath"
8 "path/filepath"
9 "time"
9 "time"
10
10
11− "github.com/opencode-ai/opencode/internal/config"
11+ "github.com/charmbracelet/crush/internal/config"
12− "github.com/opencode-ai/opencode/internal/diff"
12+ "github.com/charmbracelet/crush/internal/diff"
13− "github.com/opencode-ai/opencode/internal/history"
13+ "github.com/charmbracelet/crush/internal/history"
14− "github.com/opencode-ai/opencode/internal/logging"
14+ "github.com/charmbracelet/crush/internal/logging"
15− "github.com/opencode-ai/opencode/internal/lsp"
15+ "github.com/charmbracelet/crush/internal/lsp"
16− "github.com/opencode-ai/opencode/internal/permission"
16+ "github.com/charmbracelet/crush/internal/permission"
17)
17)
18
18
19type PatchParams struct {
19type PatchParams struct {
internal/llm/tools/shell/shell.go+9−9
@@ -12,7 +12,7 @@ import (
12 "syscall"
12 "syscall"
13 "time"
13 "time"
14
14
15− "github.com/opencode-ai/opencode/internal/config"
15+ "github.com/charmbracelet/crush/internal/config"
16)
16)
17
17
18type PersistentShell struct {
18type PersistentShell struct {
@@ -61,23 +61,23 @@ func GetPersistentShell(workingDir string) *PersistentShell {
61func newPersistentShell(cwd string) *PersistentShell {
61func newPersistentShell(cwd string) *PersistentShell {
62 // Get shell configuration from config
62 // Get shell configuration from config
63 cfg := config.Get()
63 cfg := config.Get()
64−
64+
65 // Default to environment variable if config is not set or nil
65 // Default to environment variable if config is not set or nil
66 var shellPath string
66 var shellPath string
67 var shellArgs []string
67 var shellArgs []string
68−
68+
69 if cfg != nil {
69 if cfg != nil {
70 shellPath = cfg.Shell.Path
70 shellPath = cfg.Shell.Path
71 shellArgs = cfg.Shell.Args
71 shellArgs = cfg.Shell.Args
72 }
72 }
73−
73+
74 if shellPath == "" {
74 if shellPath == "" {
75 shellPath = os.Getenv("SHELL")
75 shellPath = os.Getenv("SHELL")
76 if shellPath == "" {
76 if shellPath == "" {
77 shellPath = "/bin/bash"
77 shellPath = "/bin/bash"
78 }
78 }
79 }
79 }
80−
80+
81 // Default shell args
81 // Default shell args
82 if len(shellArgs) == 0 {
82 if len(shellArgs) == 0 {
83 shellArgs = []string{"-l"}
83 shellArgs = []string{"-l"}
@@ -149,10 +149,10 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
149 }
149 }
150
150
151 tempDir := os.TempDir()
151 tempDir := os.TempDir()
152− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano()))
152+ stdoutFile := filepath.Join(tempDir, fmt.Sprintf("crush-stdout-%d", time.Now().UnixNano()))
153− stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano()))
153+ stderrFile := filepath.Join(tempDir, fmt.Sprintf("crush-stderr-%d", time.Now().UnixNano()))
154− statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano()))
154+ statusFile := filepath.Join(tempDir, fmt.Sprintf("crush-status-%d", time.Now().UnixNano()))
155− cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano()))
155+ cwdFile := filepath.Join(tempDir, fmt.Sprintf("crush-cwd-%d", time.Now().UnixNano()))
156
156
157 defer func() {
157 defer func() {
158 os.Remove(stdoutFile)
158 os.Remove(stdoutFile)
internal/llm/tools/sourcegraph.go+1−1
@@ -218,7 +218,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
218 }
218 }
219
219
220 req.Header.Set("Content-Type", "application/json")
220 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")
222
222
223 resp, err := client.Do(req)
223 resp, err := client.Do(req)
224 if err != nil {
224 if err != nil {
internal/llm/tools/view.go+2−2
@@ -10,8 +10,8 @@ import (
10 "path/filepath"
10 "path/filepath"
11 "strings"
11 "strings"
12
12
13− "github.com/opencode-ai/opencode/internal/config"
13+ "github.com/charmbracelet/crush/internal/config"
14− "github.com/opencode-ai/opencode/internal/lsp"
14+ "github.com/charmbracelet/crush/internal/lsp"
15)
15)
16
16
17type ViewParams struct {
17type ViewParams struct {
internal/llm/tools/write.go+6−6
@@ -9,12 +9,12 @@ import (
9 "strings"
9 "strings"
10 "time"
10 "time"
11
11
12− "github.com/opencode-ai/opencode/internal/config"
12+ "github.com/charmbracelet/crush/internal/config"
13− "github.com/opencode-ai/opencode/internal/diff"
13+ "github.com/charmbracelet/crush/internal/diff"
14− "github.com/opencode-ai/opencode/internal/history"
14+ "github.com/charmbracelet/crush/internal/history"
15− "github.com/opencode-ai/opencode/internal/logging"
15+ "github.com/charmbracelet/crush/internal/logging"
16− "github.com/opencode-ai/opencode/internal/lsp"
16+ "github.com/charmbracelet/crush/internal/lsp"
17− "github.com/opencode-ai/opencode/internal/permission"
17+ "github.com/charmbracelet/crush/internal/permission"
18)
18)
19
19
20type WriteParams struct {
20type WriteParams struct {
internal/logging/logger.go+1−1
@@ -54,7 +54,7 @@ func RecoverPanic(name string, cleanup func()) {
54
54
55 // Create a timestamped panic log file
55 // Create a timestamped panic log file
56 timestamp := time.Now().Format("20060102-150405")
56 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)
58
58
59 file, err := os.Create(filename)
59 file, err := os.Create(filename)
60 if err != nil {
60 if err != nil {
internal/logging/writer.go+1−1
@@ -8,8 +8,8 @@ import (
8 "sync"
8 "sync"
9 "time"
9 "time"
10
10
11+ "github.com/charmbracelet/crush/internal/pubsub"
11 "github.com/go-logfmt/logfmt"
12 "github.com/go-logfmt/logfmt"
12− "github.com/opencode-ai/opencode/internal/pubsub"
13)
13)
14
14
15const (
15const (
internal/lsp/client.go+3−3
@@ -14,9 +14,9 @@ import (
14 "sync/atomic"
14 "sync/atomic"
15 "time"
15 "time"
16
16
17− "github.com/opencode-ai/opencode/internal/config"
17+ "github.com/charmbracelet/crush/internal/config"
18− "github.com/opencode-ai/opencode/internal/logging"
18+ "github.com/charmbracelet/crush/internal/logging"
19− "github.com/opencode-ai/opencode/internal/lsp/protocol"
19+ "github.com/charmbracelet/crush/internal/lsp/protocol"
20)
20)
21
21
22type Client struct {
22type Client struct {
internal/lsp/handlers.go+4−4
@@ -3,10 +3,10 @@ package lsp
3import (
3import (
4 "encoding/json"
4 "encoding/json"
5
5
6− "github.com/opencode-ai/opencode/internal/config"
6+ "github.com/charmbracelet/crush/internal/config"
7− "github.com/opencode-ai/opencode/internal/logging"
7+ "github.com/charmbracelet/crush/internal/logging"
8− "github.com/opencode-ai/opencode/internal/lsp/protocol"
8+ "github.com/charmbracelet/crush/internal/lsp/protocol"
9− "github.com/opencode-ai/opencode/internal/lsp/util"
9+ "github.com/charmbracelet/crush/internal/lsp/util"
10)
10)
11
11
12// Requests
12// Requests
internal/lsp/language.go+1−1
@@ -4,7 +4,7 @@ import (
4 "path/filepath"
4 "path/filepath"
5 "strings"
5 "strings"
6
6
7− "github.com/opencode-ai/opencode/internal/lsp/protocol"
7+ "github.com/charmbracelet/crush/internal/lsp/protocol"
8)
8)
9
9
10func DetectLanguageID(uri string) protocol.LanguageKind {
10func DetectLanguageID(uri string) protocol.LanguageKind {
internal/lsp/methods.go+1−1
@@ -4,7 +4,7 @@ package lsp
4import (
4import (
5 "context"
5 "context"
6
6
7− "github.com/opencode-ai/opencode/internal/lsp/protocol"
7+ "github.com/charmbracelet/crush/internal/lsp/protocol"
8)
8)
9
9
10// Implementation sends a textDocument/implementation request to the LSP server.
10// Implementation sends a textDocument/implementation request to the LSP server.
internal/lsp/transport.go+2−2
@@ -8,8 +8,8 @@ import (
8 "io"
8 "io"
9 "strings"
9 "strings"
10
10
11− "github.com/opencode-ai/opencode/internal/config"
11+ "github.com/charmbracelet/crush/internal/config"
12− "github.com/opencode-ai/opencode/internal/logging"
12+ "github.com/charmbracelet/crush/internal/logging"
13)
13)
14
14
15// Write writes an LSP message to the given writer
15// Write writes an LSP message to the given writer
internal/lsp/util/edit.go+1−1
@@ -7,7 +7,7 @@ import (
7 "sort"
7 "sort"
8 "strings"
8 "strings"
9
9
10− "github.com/opencode-ai/opencode/internal/lsp/protocol"
10+ "github.com/charmbracelet/crush/internal/lsp/protocol"
11)
11)
12
12
13func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
13func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
internal/lsp/watcher/watcher.go+4−5
@@ -10,11 +10,11 @@ import (
10 "time"
10 "time"
11
11
12 "github.com/bmatcuk/doublestar/v4"
12 "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"
13 "github.com/fsnotify/fsnotify"
17 "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"
18)
18)
19
19
20// WorkspaceWatcher manages LSP file watching
20// WorkspaceWatcher manages LSP file watching
@@ -401,7 +401,6 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
401 "watched", matched,
401 "watched", matched,
402 "kind", kind,
402 "kind", kind,
403 )
403 )
404−
405 }
404 }
406
405
407 // Check if this path should be watched according to server registrations
406 // Check if this path should be watched according to server registrations
internal/message/content.go+1−1
@@ -5,7 +5,7 @@ import (
5 "slices"
5 "slices"
6 "time"
6 "time"
7
7
8− "github.com/opencode-ai/opencode/internal/llm/models"
8+ "github.com/charmbracelet/crush/internal/llm/models"
9)
9)
10
10
11type MessageRole string
11type MessageRole string
internal/message/message.go+3−4
@@ -7,10 +7,10 @@ import (
7 "fmt"
7 "fmt"
8 "time"
8 "time"
9
9
10+ "github.com/charmbracelet/crush/internal/db"
11+ "github.com/charmbracelet/crush/internal/llm/models"
12+ "github.com/charmbracelet/crush/internal/pubsub"
10 "github.com/google/uuid"
13 "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"
14)
14)
15
15
16type CreateMessageParams struct {
16type CreateMessageParams struct {
@@ -274,7 +274,6 @@ func unmarshallParts(data []byte) ([]ContentPart, error) {
274 default:
274 default:
275 return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
275 return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
276 }
276 }
277−
278 }
277 }
279
278
280 return parts, nil
279 return parts, nil
internal/permission/permission.go+4−4
@@ -6,9 +6,9 @@ import (
6 "slices"
6 "slices"
7 "sync"
7 "sync"
8
8
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/pubsub"
9 "github.com/google/uuid"
11 "github.com/google/uuid"
10− "github.com/opencode-ai/opencode/internal/config"
11− "github.com/opencode-ai/opencode/internal/pubsub"
12)
12)
13
13
14var ErrorPermissionDenied = errors.New("permission denied")
14var ErrorPermissionDenied = errors.New("permission denied")
@@ -34,7 +34,7 @@ type PermissionRequest struct {
34
34
35type Service interface {
35type Service interface {
36 pubsub.Suscriber[PermissionRequest]
36 pubsub.Suscriber[PermissionRequest]
37− GrantPersistant(permission PermissionRequest)
37+ GrantPersistent(permission PermissionRequest)
38 Grant(permission PermissionRequest)
38 Grant(permission PermissionRequest)
39 Deny(permission PermissionRequest)
39 Deny(permission PermissionRequest)
40 Request(opts CreatePermissionRequest) bool
40 Request(opts CreatePermissionRequest) bool
@@ -49,7 +49,7 @@ type permissionService struct {
49 autoApproveSessions []string
49 autoApproveSessions []string
50}
50}
51
51
52−func (s *permissionService) GrantPersistant(permission PermissionRequest) {
52+func (s *permissionService) GrantPersistent(permission PermissionRequest) {
53 respCh, ok := s.pendingRequests.Load(permission.ID)
53 respCh, ok := s.pendingRequests.Load(permission.ID)
54 if ok {
54 if ok {
55 respCh.(chan bool) <- true
55 respCh.(chan bool) <- true
internal/session/session.go+2−2
@@ -4,9 +4,9 @@ import (
4 "context"
4 "context"
5 "database/sql"
5 "database/sql"
6
6
7+ "github.com/charmbracelet/crush/internal/db"
8+ "github.com/charmbracelet/crush/internal/pubsub"
7 "github.com/google/uuid"
9 "github.com/google/uuid"
8− "github.com/opencode-ai/opencode/internal/db"
9− "github.com/opencode-ai/opencode/internal/pubsub"
10)
10)
11
11
12type Session struct {
12type 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 @@
1package chat
1package chat
2
2
3import (
3import (
4− "fmt"
4+ "context"
5− "sort"
5+ "time"
6−
6+
7− "github.com/charmbracelet/lipgloss"
7+ tea "github.com/charmbracelet/bubbletea/v2"
8− "github.com/charmbracelet/x/ansi"
8+ "github.com/charmbracelet/crush/internal/app"
9− "github.com/opencode-ai/opencode/internal/config"
9+ "github.com/charmbracelet/crush/internal/llm/agent"
10− "github.com/opencode-ai/opencode/internal/message"
10+ "github.com/charmbracelet/crush/internal/message"
11− "github.com/opencode-ai/opencode/internal/session"
11+ "github.com/charmbracelet/crush/internal/pubsub"
12− "github.com/opencode-ai/opencode/internal/tui/styles"
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"
13− "github.com/opencode-ai/opencode/internal/tui/theme"
15+ "github.com/charmbracelet/crush/internal/tui/layout"
14− "github.com/opencode-ai/opencode/internal/version"
16+ "github.com/charmbracelet/crush/internal/tui/util"
17+ "github.com/charmbracelet/lipgloss/v2"
15)
18)
16
19
17type SendMsg struct {
20type SendMsg struct {
@@ -23,119 +26,472 @@ type SessionSelectedMsg = session.Session
23
26
24type SessionClearedMsg struct{}
27type SessionClearedMsg struct{}
25
28
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{})
27
87
28−func header(width int) string {
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(
29− return lipgloss.JoinVertical(
103+ lipgloss.JoinVertical(
30− lipgloss.Top,
104+ lipgloss.Left,
31− logo(width),
105+ m.listCmp.View().String(),
32− repo(width),
106+ ),
33− "",
107+ )
34− cwd(width),
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,
35 )
157 )
158+ return tea.Batch(cmds...)
36}
159}
37
160
38−func lspsConfigured(width int) string {
161+// handleMessageEvent processes different types of message events (created/updated).
39− cfg := config.Get()
162+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
40− title := "LSP Configuration"
163+ switch event.Type {
41− title = ansi.Truncate(title, width, "…")
164+ case pubsub.CreatedEvent:
42−
165+ if event.Payload.SessionID != m.session.ID {
43− t := theme.CurrentTheme()
166+ return m.handleChildSession(event)
44− baseStyle := styles.BaseStyle()
167+ }
45−
168+ if m.messageExists(event.Payload.ID) {
46− lsps := baseStyle.
169+ return nil
47− Width(width).
170+ }
48− Foreground(t.Primary()).
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 {
49− Bold(true).
187+ return true
50− Render(title)
188+ }
51−
189+ }
52− // Get LSP names and sort them for consistent ordering
190+ return false
53− var lspNames []string
191+}
54− for name := range cfg.LSP {
192+
55− lspNames = append(lspNames, name)
193+// handleNewMessage routes new messages to appropriate handlers based on role.
56− }
194+func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
57− sort.Strings(lspNames)
195+ switch msg.Role {
58−
196+ case message.User:
59− var lspViews []string
197+ return m.handleNewUserMessage(msg)
60− for _, name := range lspNames {
198+ case message.Assistant:
61− lsp := cfg.LSP[name]
199+ return m.handleNewAssistantMessage(msg)
62− lspName := baseStyle.
200+ case message.Tool:
63− Foreground(t.Text()).
201+ return m.handleToolMessage(msg)
64− Render(fmt.Sprintf("• %s", name))
202+ }
65−
203+ return nil
66− cmd := lsp.Command
204+}
67− cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
205+
68−
206+// handleNewUserMessage adds a new user message to the list and updates the timestamp.
69− lspPath := baseStyle.
207+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
70− Foreground(t.TextMuted()).
208+ m.lastUserMessageTime = msg.CreatedAt
71− Render(fmt.Sprintf(" (%s)", cmd))
209+ return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
72−
210+}
73− lspViews = append(lspViews,
211+
74− baseStyle.
212+// handleToolMessage updates existing tool calls with their results.
75− Width(width).
213+func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
76− Render(
214+ items := m.listCmp.Items()
77− lipgloss.JoinHorizontal(
215+ for _, tr := range msg.ToolResults() {
78− lipgloss.Left,
216+ if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
79− lspName,
217+ toolCall := items[toolCallIndex].(messages.ToolCallCmp)
80− lspPath,
218+ toolCall.SetToolResult(tr)
81− ),
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)),
82− ),
296+ ),
83 )
297 )
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+ }
84 }
332 }
85
333
86− return baseStyle.
334+ // Add new tool call if not found
87− Width(width).
335+ return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
88− Render(
336+}
89− lipgloss.JoinVertical(
337+
90− lipgloss.Left,
338+// handleNewAssistantMessage processes new assistant messages and their tool calls.
91− lsps,
339+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
92− lipgloss.JoinVertical(
340+ var cmds []tea.Cmd
93− lipgloss.Left,
341+
94− lspViews...,
342+ // Add assistant message if it should be displayed
95− ),
343+ if m.shouldShowAssistantMessage(msg) {
344+ cmd := m.listCmp.AppendItem(
345+ messages.NewMessageCmp(
346+ msg,
347+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
96 ),
348 ),
97 )
349 )
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...)
98}
360}
99
361
100−func logo(width int) string {
362+// SetSession loads and displays messages for a new session.
101− logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
363+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
102− t := theme.CurrentTheme()
364+ if m.session.ID == session.ID {
103− baseStyle := styles.BaseStyle()
365+ return nil
104−
366+ }
105− versionText := baseStyle.
367+
106− Foreground(t.TextMuted()).
368+ m.session = session
107− Render(version.Version)
369+ sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
108−
370+ if err != nil {
109− return baseStyle.
371+ return util.ReportError(err)
110− Bold(true).
372+ }
111− Width(width).
373+
112− Render(
374+ if len(sessionMessages) == 0 {
113− lipgloss.JoinHorizontal(
375+ return m.listCmp.SetItems([]util.Model{})
114− lipgloss.Left,
376+ }
115− logo,
377+
116− " ",
378+ // Initialize with first message timestamp
117− versionText,
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)),
118 ),
429 ),
119 )
430 )
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
120}
453}
121
454
122−func repo(width int) string {
455+// buildToolCallOptions creates options for tool call components based on results and status.
123− repo := "https://github.com/opencode-ai/opencode"
456+func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
124− t := theme.CurrentTheme()
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+ }
125
468
126− return styles.BaseStyle().
469+ return options
127− Foreground(t.TextMuted()).
128− Width(width).
129− Render(repo)
130}
470}
131
471
132−func cwd(width int) string {
472+// GetSize returns the current width and height of the component.
133− cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
473+func (m *messageListCmp) GetSize() (int, int) {
134− t := theme.CurrentTheme()
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+}
135
483
136− return styles.BaseStyle().
484+// Blur implements MessageListCmp.
137− Foreground(t.TextMuted()).
485+func (m *messageListCmp) Blur() tea.Cmd {
138− Width(width).
486+ return m.listCmp.Blur()
139− Render(cwd)
140}
487}
141
488
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 (
5 "strings"
5 "strings"
6 "time"
6 "time"
7
7
8− "github.com/charmbracelet/bubbles/key"
8+ "github.com/charmbracelet/bubbles/v2/key"
9− "github.com/charmbracelet/bubbles/viewport"
9+ "github.com/charmbracelet/bubbles/v2/viewport"
10− tea "github.com/charmbracelet/bubbletea"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11− "github.com/charmbracelet/lipgloss"
12− "github.com/opencode-ai/opencode/internal/logging"
11+ "github.com/charmbracelet/crush/internal/logging"
13− "github.com/opencode-ai/opencode/internal/tui/layout"
12+ "github.com/charmbracelet/crush/internal/tui/layout"
14− "github.com/opencode-ai/opencode/internal/tui/styles"
13+ "github.com/charmbracelet/crush/internal/tui/styles"
15− "github.com/opencode-ai/opencode/internal/tui/theme"
14+ "github.com/charmbracelet/crush/internal/tui/util"
15+ "github.com/charmbracelet/lipgloss/v2"
16)
16)
17
17
18type DetailComponent interface {
18type DetailComponent interface {
19− tea.Model
19+ util.Model
20 layout.Sizeable
20 layout.Sizeable
21 layout.Bindings
21 layout.Bindings
22}
22}
@@ -50,45 +50,57 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50
50
51func (i *detailCmp) updateContent() {
51func (i *detailCmp) updateContent() {
52 var content strings.Builder
52 var content strings.Builder
53− t := theme.CurrentTheme()
53+ t := styles.CurrentTheme()
54
54
55− // Format the header with timestamp and level
55+ if i.currentLog.ID == "" {
56− timeStyle := lipgloss.NewStyle().Foreground(t.TextMuted())
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
57 levelStyle := getLevelStyle(i.currentLog.Level)
62 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
58
69
70+ // Header line
59 header := lipgloss.JoinHorizontal(
71 header := lipgloss.JoinHorizontal(
60− lipgloss.Center,
72+ lipgloss.Left,
73+ timeStr,
74+ " ",
61− timeStyle.Render(i.currentLog.Time.Format(time.RFC3339)),
75+ timeStyle.Render(relativeTime),
62− " ",
63− levelStyle.Render(i.currentLog.Level),
64 )
76 )
65
77
66− content.WriteString(lipgloss.NewStyle().Bold(true).Render(header))
78+ content.WriteString(levelBadge)
79+ content.WriteString("\n\n")
80+ content.WriteString(header)
67 content.WriteString("\n\n")
81 content.WriteString("\n\n")
68
82
69− // Message with styling
83+ // Message section
70− messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
84+ messageHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true)
71− content.WriteString(messageStyle.Render("Message:"))
85+ content.WriteString(messageHeaderStyle.Render("Message"))
72 content.WriteString("\n")
86 content.WriteString("\n")
73− content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
87+ content.WriteString(i.currentLog.Message)
74 content.WriteString("\n\n")
88 content.WriteString("\n\n")
75
89
76 // Attributes section
90 // Attributes section
77 if len(i.currentLog.Attributes) > 0 {
91 if len(i.currentLog.Attributes) > 0 {
78− attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
92+ attrHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true)
79− content.WriteString(attrHeaderStyle.Render("Attributes:"))
93+ content.WriteString(attrHeaderStyle.Render("Attributes"))
80 content.WriteString("\n")
94 content.WriteString("\n")
81
95
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−
86 for _, attr := range i.currentLog.Attributes {
96 for _, attr := range i.currentLog.Attributes {
97+ keyStyle := t.S().Base.Foreground(t.Accent)
98+ valueStyle := t.S().Text
87 attrLine := fmt.Sprintf("%s: %s",
99 attrLine := fmt.Sprintf("%s: %s",
88 keyStyle.Render(attr.Key),
100 keyStyle.Render(attr.Key),
89 valueStyle.Render(attr.Value),
101 valueStyle.Render(attr.Value),
90 )
102 )
91− content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine))
103+ content.WriteString(attrLine)
92 content.WriteString("\n")
104 content.WriteString("\n")
93 }
105 }
94 }
106 }
@@ -97,26 +109,53 @@ func (i *detailCmp) updateContent() {
97}
109}
98
110
99func getLevelStyle(level string) lipgloss.Style {
111func getLevelStyle(level string) lipgloss.Style {
112+ t := styles.CurrentTheme()
100− style := lipgloss.NewStyle().Bold(true)
113+ style := t.S().Base.Bold(true)
101− t := theme.CurrentTheme()
114+
102−
103 switch strings.ToLower(level) {
115 switch strings.ToLower(level) {
104 case "info":
116 case "info":
105− return style.Foreground(t.Info())
117+ return style.Foreground(t.White).Background(t.Info)
106 case "warn", "warning":
118 case "warn", "warning":
107− return style.Foreground(t.Warning())
119+ return style.Foreground(t.White).Background(t.Warning)
108 case "error", "err":
120 case "error", "err":
109− return style.Foreground(t.Error())
121+ return style.Foreground(t.White).Background(t.Error)
110 case "debug":
122 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)
112 default:
126 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)))
114 }
147 }
115}
148}
116
149
117−func (i *detailCmp) View() string {
150+func (i *detailCmp) View() tea.View {
118− t := theme.CurrentTheme()
151+ t := styles.CurrentTheme()
119− return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), t.Background())
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()))
120}
159}
121
160
122func (i *detailCmp) GetSize() (int, int) {
161func (i *detailCmp) GetSize() (int, int) {
@@ -124,10 +163,11 @@ func (i *detailCmp) GetSize() (int, int) {
124}
163}
125
164
126func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
165func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
166+ logging.Info("Setting size for detail component", "width", width, "height", height)
127 i.width = width
167 i.width = width
128 i.height = height
168 i.height = height
129− i.viewport.Width = i.width
169+ i.viewport.SetWidth(i.width - 4)
130− i.viewport.Height = i.height
170+ i.viewport.SetHeight(i.height - 4)
131 i.updateContent()
171 i.updateContent()
132 return nil
172 return nil
133}
173}
@@ -138,6 +178,6 @@ func (i *detailCmp) BindingKeys() []key.Binding {
138
178
139func NewLogsDetails() DetailComponent {
179func NewLogsDetails() DetailComponent {
140 return &detailCmp{
180 return &detailCmp{
141− viewport: viewport.New(0, 0),
181+ viewport: viewport.New(),
142 }
182 }
143}
183}
internal/tui/components/logs/table.go+124−58
@@ -1,72 +1,105 @@
1package logs
1package logs
2
2
3import (
3import (
4− "encoding/json"
4+ "fmt"
5 "slices"
5 "slices"
6−
6+ "strings"
7+
7− "github.com/charmbracelet/bubbles/key"
8+ "github.com/charmbracelet/bubbles/v2/key"
8− "github.com/charmbracelet/bubbles/table"
9+ "github.com/charmbracelet/bubbles/v2/table"
9− tea "github.com/charmbracelet/bubbletea"
10+ tea "github.com/charmbracelet/bubbletea/v2"
10− "github.com/opencode-ai/opencode/internal/logging"
11+ "github.com/charmbracelet/crush/internal/logging"
11− "github.com/opencode-ai/opencode/internal/pubsub"
12+ "github.com/charmbracelet/crush/internal/pubsub"
12− "github.com/opencode-ai/opencode/internal/tui/layout"
13+ "github.com/charmbracelet/crush/internal/tui/layout"
13− "github.com/opencode-ai/opencode/internal/tui/styles"
14+ "github.com/charmbracelet/crush/internal/tui/styles"
14− "github.com/opencode-ai/opencode/internal/tui/theme"
15+ "github.com/charmbracelet/crush/internal/tui/util"
15− "github.com/opencode-ai/opencode/internal/tui/util"
16+ "github.com/charmbracelet/lipgloss/v2"
16)
17)
17
18
18type TableComponent interface {
19type TableComponent interface {
19− tea.Model
20+ util.Model
20 layout.Sizeable
21 layout.Sizeable
21 layout.Bindings
22 layout.Bindings
22}
23}
23
24
24type tableCmp struct {
25type tableCmp struct {
25 table table.Model
26 table table.Model
27+ logs []logging.LogMessage
26}
28}
27
29
28type selectedLogMsg logging.LogMessage
30type selectedLogMsg logging.LogMessage
29
31
30func (i *tableCmp) Init() tea.Cmd {
32func (i *tableCmp) Init() tea.Cmd {
33+ i.logs = logging.List()
31 i.setRows()
34 i.setRows()
32 return nil
35 return nil
33}
36}
34
37
35func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
38func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
36 var cmds []tea.Cmd
39 var cmds []tea.Cmd
37− switch msg.(type) {
40+ switch msg := msg.(type) {
38 case pubsub.Event[logging.LogMessage]:
41 case pubsub.Event[logging.LogMessage]:
39− i.setRows()
42+ return i, func() tea.Msg {
40− return i, nil
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+ }
41 }
62 }
42− prevSelectedRow := i.table.SelectedRow()
43 t, cmd := i.table.Update(msg)
63 t, cmd := i.table.Update(msg)
44 cmds = append(cmds, cmd)
64 cmds = append(cmds, cmd)
45 i.table = t
65 i.table = t
46− selectedRow := i.table.SelectedRow()
66+
47− if selectedRow != nil {
67+ cmds = append(cmds, func() tea.Msg {
48− if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
49− var log logging.LogMessage
50− for _, row := range logging.List() {
68+ for _, log := range logging.List() {
51− if row.ID == selectedRow[0] {
69+ if log.ID == i.table.SelectedRow()[1] {
52− log = row
70+ // If the selected row matches the log ID, return the selected log message
53− break
54− }
55− }
56− if log.ID != "" {
57− cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
71+ return selectedLogMsg(log)
58 }
72 }
59 }
73 }
74+ return nil
60− }
75+ })
61 return i, tea.Batch(cmds...)
76 return i, tea.Batch(cmds...)
62}
77}
63
78
64−func (i *tableCmp) View() string {
79+func (i *tableCmp) View() tea.View {
65− t := theme.CurrentTheme()
80+ t := styles.CurrentTheme()
66 defaultStyles := table.DefaultStyles()
81 defaultStyles := table.DefaultStyles()
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
67− defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
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+
68 i.table.SetStyles(defaultStyles)
101 i.table.SetStyles(defaultStyles)
69− return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), t.Background())
102+ return tea.NewView(i.table.View())
70}
103}
71
104
72func (i *tableCmp) GetSize() (int, int) {
105func (i *tableCmp) GetSize() (int, int) {
@@ -76,12 +109,30 @@ func (i *tableCmp) GetSize() (int, int) {
76func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
109func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
77 i.table.SetWidth(width)
110 i.table.SetWidth(width)
78 i.table.SetHeight(height)
111 i.table.SetHeight(height)
79− cloumns := i.table.Columns()
112+
80− for i, col := range cloumns {
113+ columnWidth := (width - 10) / 4
81− col.Width = (width / len(cloumns)) - 2
114+ i.table.SetColumns([]table.Column{
82− cloumns[i] = col
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,
83− }
130+ },
84− i.table.SetColumns(cloumns)
131+ {
132+ Title: "Attributes",
133+ Width: columnWidth,
134+ },
135+ })
85 return nil
136 return nil
86}
137}
87
138
@@ -92,39 +143,54 @@ func (i *tableCmp) BindingKeys() []key.Binding {
92func (i *tableCmp) setRows() {
143func (i *tableCmp) setRows() {
93 rows := []table.Row{}
144 rows := []table.Row{}
94
145
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 {
97 if a.Time.Before(b.Time) {
147 if a.Time.Before(b.Time) {
98− return 1
148+ return -1
99 }
149 }
100 if a.Time.After(b.Time) {
150 if a.Time.After(b.Time) {
101− return -1
151+ return 1
102 }
152 }
103 return 0
153 return 0
104 })
154 })
105
155
106− for _, log := range logs {
156+ for _, log := range i.logs {
107− bm, _ := json.Marshal(log.Attributes)
157+ rows = append(rows, logToRow(log))
158+ }
159+ i.table.SetRows(rows)
160+}
108
161
109− row := table.Row{
162+func logToRow(log logging.LogMessage) table.Row {
110− log.ID,
163+ // Format attributes as JSON string
111− log.Time.Format("15:04:05"),
164+ var attrStr string
112− log.Level,
165+ if len(log.Attributes) > 0 {
113− log.Message,
166+ var parts []string
114− string(bm),
167+ for _, attr := range log.Attributes {
168+ parts = append(parts, fmt.Sprintf(`{"Key":"%s","Value":"%s"}`, attr.Key, attr.Value))
115 }
169 }
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,
117 }
184 }
118− i.table.SetRows(rows)
119}
185}
120
186
121func NewLogsTable() TableComponent {
187func NewLogsTable() TableComponent {
122 columns := []table.Column{
188 columns := []table.Column{
123− {Title: "ID", Width: 4},
189+ {Title: "Level"},
190+ {Title: "ID"},
124− {Title: "Time", Width: 4},
191+ {Title: "Time"},
125− {Title: "Level", Width: 10},
126− {Title: "Message", Width: 10},
192+ {Title: "Message"},
127− {Title: "Attributes", Width: 10},
193+ {Title: "Attributes"},
128 }
194 }
129
195
130 tableModel := table.New(
196 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 @@
1package layout
1package layout
2
2
3import (
3import (
4− "github.com/charmbracelet/bubbles/key"
4+ "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/tui/styles"
7+ "github.com/charmbracelet/crush/internal/tui/util"
6− "github.com/charmbracelet/lipgloss"
8+ "github.com/charmbracelet/lipgloss/v2"
7− "github.com/opencode-ai/opencode/internal/tui/theme"
8)
9)
9
10
10type Container interface {
11type Container interface {
11− tea.Model
12+ util.Model
12 Sizeable
13 Sizeable
13 Bindings
14 Bindings
15+ Positionable
16+ Focusable
14}
17}
15type container struct {
18type container struct {
16− width int
19+ width int
17− height int
20+ height int
21+ isFocused bool
18
22
23+ x, y int
24+
19− content tea.Model
25+ content util.Model
20
26
21 // Style options
27 // Style options
22 paddingTop int
28 paddingTop int
@@ -31,23 +37,47 @@ type container struct {
31 borderStyle lipgloss.Border
37 borderStyle lipgloss.Border
32}
38}
33
39
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+
34func (c *container) Init() tea.Cmd {
55func (c *container) Init() tea.Cmd {
35 return c.content.Init()
56 return c.content.Init()
36}
57}
37
58
38func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
59func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60+ switch msg := msg.(type) {
61+ case tea.KeyPressMsg:
62+ if c.IsFocused() {
39− u, cmd := c.content.Update(msg)
63+ u, cmd := c.content.Update(msg)
64+ c.content = u.(util.Model)
65+ return c, cmd
66+ }
67+ return c, nil
68+ default:
40− c.content = u
69+ u, cmd := c.content.Update(msg)
70+ c.content = u.(util.Model)
41− return c, cmd
71+ return c, cmd
72+ }
42}
73}
43
74
44−func (c *container) View() string {
75+func (c *container) View() tea.View {
45− t := theme.CurrentTheme()
76+ t := styles.CurrentTheme()
46− style := lipgloss.NewStyle()
47 width := c.width
77 width := c.width
48 height := c.height
78 height := c.height
49
79
50− style = style.Background(t.Background())
80+ style := t.S().Base
51
81
52 // Apply border if any side is enabled
82 // Apply border if any side is enabled
53 if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
83 if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
@@ -65,7 +95,7 @@ func (c *container) View() string {
65 width--
95 width--
66 }
96 }
67 style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
97 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)
69 }
99 }
70 style = style.
100 style = style.
71 Width(width).
101 Width(width).
@@ -75,7 +105,11 @@ func (c *container) View() string {
75 PaddingBottom(c.paddingBottom).
105 PaddingBottom(c.paddingBottom).
76 PaddingLeft(c.paddingLeft)
106 PaddingLeft(c.paddingLeft)
77
107
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
79}
113}
80
114
81func (c *container) SetSize(width, height int) tea.Cmd {
115func (c *container) SetSize(width, height int) tea.Cmd {
@@ -114,6 +148,15 @@ func (c *container) GetSize() (int, int) {
114 return c.width, c.height
148 return c.width, c.height
115}
149}
116
150
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+
117func (c *container) BindingKeys() []key.Binding {
160func (c *container) BindingKeys() []key.Binding {
118 if b, ok := c.content.(Bindings); ok {
161 if b, ok := c.content.(Bindings); ok {
119 return b.BindingKeys()
162 return b.BindingKeys()
@@ -121,20 +164,31 @@ func (c *container) BindingKeys() []key.Binding {
121 return []key.Binding{}
164 return []key.Binding{}
122}
165}
123
166
167+// Blur implements Container.
124−type ContainerOption func(*container)
168+func (c *container) Blur() tea.Cmd {
125−
169+ c.isFocused = false
126−func NewContainer(content tea.Model, options ...ContainerOption) Container {
170+ if focusable, ok := c.content.(Focusable); ok {
127−
171+ return focusable.Blur()
128− c := &container{
129− content: content,
130− borderStyle: lipgloss.NormalBorder(),
131 }
172 }
173+ return nil
174+}
132
175
133− for _, option := range options {
176+// Focus implements Container.
134− option(c)
177+func (c *container) Focus() tea.Cmd {
178+ c.isFocused = true
179+ if focusable, ok := c.content.(Focusable); ok {
180+ return focusable.Focus()
135 }
181 }
182+ return nil
183+}
136
184
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+ }
137− return c
191+ return isFocused
138}
192}
139
193
140// Padding options
194// Padding options
internal/tui/layout/layout.go+11−2
@@ -3,8 +3,9 @@ package layout
3import (
3import (
4 "reflect"
4 "reflect"
5
5
6− "github.com/charmbracelet/bubbles/key"
6+ "github.com/charmbracelet/bubbles/v2/help"
7+ "github.com/charmbracelet/bubbles/v2/key"
7− tea "github.com/charmbracelet/bubbletea"
8+ tea "github.com/charmbracelet/bubbletea/v2"
8)
9)
9
10
10type Focusable interface {
11type Focusable interface {
@@ -22,6 +23,14 @@ type Bindings interface {
22 BindingKeys() []key.Binding
23 BindingKeys() []key.Binding
23}
24}
24
25
26+type Positionable interface {
27+ SetPosition(x, y int) tea.Cmd
28+}
29+
30+type Help interface {
31+ Help() help.KeyMap
32+}
33+
25func KeyMapToSlice(t any) (bindings []key.Binding) {
34func KeyMapToSlice(t any) (bindings []key.Binding) {
26 typ := reflect.TypeOf(t)
35 typ := reflect.TypeOf(t)
27 if typ.Kind() != reflect.Struct {
36 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 @@
1package layout
1package layout
2
2
3import (
3import (
4− "github.com/charmbracelet/bubbles/key"
4+ "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/tui/styles"
7+ "github.com/charmbracelet/crush/internal/tui/util"
6− "github.com/charmbracelet/lipgloss"
8+ "github.com/charmbracelet/lipgloss/v2"
7− "github.com/opencode-ai/opencode/internal/tui/theme"
9+)
10+
11+type LayoutPanel string
12+
13+const (
14+ LeftPanel LayoutPanel = "left"
15+ RightPanel LayoutPanel = "right"
16+ BottomPanel LayoutPanel = "bottom"
8)
17)
9
18
10type SplitPaneLayout interface {
19type SplitPaneLayout interface {
11− tea.Model
20+ util.Model
12 Sizeable
21 Sizeable
13 Bindings
22 Bindings
14 SetLeftPanel(panel Container) tea.Cmd
23 SetLeftPanel(panel Container) tea.Cmd
@@ -18,17 +27,23 @@ type SplitPaneLayout interface {
18 ClearLeftPanel() tea.Cmd
27 ClearLeftPanel() tea.Cmd
19 ClearRightPanel() tea.Cmd
28 ClearRightPanel() tea.Cmd
20 ClearBottomPanel() tea.Cmd
29 ClearBottomPanel() tea.Cmd
30+
31+ FocusPanel(panel LayoutPanel) tea.Cmd
21}
32}
22
33
23type splitPaneLayout struct {
34type splitPaneLayout struct {
24− width int
35+ width int
25− height int
36+ height int
37+
26 ratio float64
38 ratio float64
27 verticalRatio float64
39 verticalRatio float64
28
40
29 rightPanel Container
41 rightPanel Container
30 leftPanel Container
42 leftPanel Container
31 bottomPanel Container
43 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
32}
47}
33
48
34type SplitPaneOption func(*splitPaneLayout)
49type SplitPaneOption func(*splitPaneLayout)
@@ -85,17 +100,17 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85 return s, tea.Batch(cmds...)
100 return s, tea.Batch(cmds...)
86}
101}
87
102
88−func (s *splitPaneLayout) View() string {
103+func (s *splitPaneLayout) View() tea.View {
89 var topSection string
104 var topSection string
90
105
91 if s.leftPanel != nil && s.rightPanel != nil {
106 if s.leftPanel != nil && s.rightPanel != nil {
92 leftView := s.leftPanel.View()
107 leftView := s.leftPanel.View()
93 rightView := s.rightPanel.View()
108 rightView := s.rightPanel.View()
94− topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
109+ topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String())
95 } else if s.leftPanel != nil {
110 } else if s.leftPanel != nil {
96− topSection = s.leftPanel.View()
111+ topSection = s.leftPanel.View().String()
97 } else if s.rightPanel != nil {
112 } else if s.rightPanel != nil {
98− topSection = s.rightPanel.View()
113+ topSection = s.rightPanel.View().String()
99 } else {
114 } else {
100 topSection = ""
115 topSection = ""
101 }
116 }
@@ -104,25 +119,32 @@ func (s *splitPaneLayout) View() string {
104
119
105 if s.bottomPanel != nil && topSection != "" {
120 if s.bottomPanel != nil && topSection != "" {
106 bottomView := s.bottomPanel.View()
121 bottomView := s.bottomPanel.View()
107− finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
122+ finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String())
108 } else if s.bottomPanel != nil {
123 } else if s.bottomPanel != nil {
109− finalView = s.bottomPanel.View()
124+ finalView = s.bottomPanel.View().String()
110 } else {
125 } else {
111 finalView = topSection
126 finalView = topSection
112 }
127 }
113
128
114− if finalView != "" {
129+ // TODO: think of a better way to handle multiple cursors
115− t := theme.CurrentTheme()
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+ }
116
138
117− style := lipgloss.NewStyle().
139+ t := styles.CurrentTheme()
118− Width(s.width).
119− Height(s.height).
120− Background(t.Background())
121
140
122− return style.Render(finalView)
141+ style := t.S().Base.
123− }
142+ Width(s.width).
143+ Height(s.height)
124
144
125− return finalView
145+ view := tea.NewView(style.Render(finalView))
146+ view.SetCursor(cursor)
147+ return view
126}
148}
127
149
128func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
150func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
@@ -130,9 +152,19 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
130 s.height = height
152 s.height = height
131
153
132 var topHeight, bottomHeight int
154 var topHeight, bottomHeight int
155+ var cmds []tea.Cmd
133 if s.bottomPanel != nil {
156 if s.bottomPanel != nil {
157+ if s.fixedBottomHeight > 0 {
158+ bottomHeight = s.fixedBottomHeight
159+ topHeight = height - bottomHeight
160+ } else {
134− topHeight = int(float64(height) * s.verticalRatio)
161+ topHeight = int(float64(height) * s.verticalRatio)
135− bottomHeight = height - topHeight
162+ bottomHeight = height - topHeight
163+ if bottomHeight <= 0 {
164+ bottomHeight = 2
165+ topHeight = height - bottomHeight
166+ }
167+ }
136 } else {
168 } else {
137 topHeight = height
169 topHeight = height
138 bottomHeight = 0
170 bottomHeight = 0
@@ -140,8 +172,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
140
172
141 var leftWidth, rightWidth int
173 var leftWidth, rightWidth int
142 if s.leftPanel != nil && s.rightPanel != nil {
174 if s.leftPanel != nil && s.rightPanel != nil {
175+ if s.fixedRightWidth > 0 {
176+ rightWidth = s.fixedRightWidth
177+ leftWidth = width - rightWidth
178+ } else {
143− leftWidth = int(float64(width) * s.ratio)
179+ leftWidth = int(float64(width) * s.ratio)
144− rightWidth = width - leftWidth
180+ rightWidth = width - leftWidth
181+ if rightWidth <= 0 {
182+ rightWidth = 2
183+ leftWidth = width - rightWidth
184+ }
185+ }
145 } else if s.leftPanel != nil {
186 } else if s.leftPanel != nil {
146 leftWidth = width
187 leftWidth = width
147 rightWidth = 0
188 rightWidth = 0
@@ -150,20 +191,28 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
150 rightWidth = width
191 rightWidth = width
151 }
192 }
152
193
153− var cmds []tea.Cmd
154 if s.leftPanel != nil {
194 if s.leftPanel != nil {
155 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
195 cmd := s.leftPanel.SetSize(leftWidth, topHeight)
156 cmds = append(cmds, cmd)
196 cmds = append(cmds, cmd)
197+ if positionable, ok := s.leftPanel.(Positionable); ok {
198+ cmds = append(cmds, positionable.SetPosition(0, 0))
199+ }
157 }
200 }
158
201
159 if s.rightPanel != nil {
202 if s.rightPanel != nil {
160 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
203 cmd := s.rightPanel.SetSize(rightWidth, topHeight)
161 cmds = append(cmds, cmd)
204 cmds = append(cmds, cmd)
205+ if positionable, ok := s.rightPanel.(Positionable); ok {
206+ cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
207+ }
162 }
208 }
163
209
164 if s.bottomPanel != nil {
210 if s.bottomPanel != nil {
165 cmd := s.bottomPanel.SetSize(width, bottomHeight)
211 cmd := s.bottomPanel.SetSize(width, bottomHeight)
166 cmds = append(cmds, cmd)
212 cmds = append(cmds, cmd)
213+ if positionable, ok := s.bottomPanel.(Positionable); ok {
214+ cmds = append(cmds, positionable.SetPosition(0, topHeight))
215+ }
167 }
216 }
168 return tea.Batch(cmds...)
217 return tea.Batch(cmds...)
169}
218}
@@ -240,11 +289,30 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
240 return keys
289 return keys
241}
290}
242
291
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+}
244
311
312+func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
245 layout := &splitPaneLayout{
313 layout := &splitPaneLayout{
246− ratio: 0.7,
314+ ratio: 0.8,
247− verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
315+ verticalRatio: 0.92, // Default 90% for top section, 10% for bottom
248 }
316 }
249 for _, option := range options {
317 for _, option := range options {
250 option(layout)
318 option(layout)
@@ -281,3 +349,15 @@ func WithVerticalRatio(ratio float64) SplitPaneOption {
281 s.verticalRatio = ratio
349 s.verticalRatio = ratio
282 }
350 }
283}
351}
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.