diff --git a/.opencode.json b/.crush.json similarity index 64% rename from .opencode.json rename to .crush.json index c4d1547a0c62aad24a470af1d503c225a5b5955b..acb2b7ccb04ceb05130449ffccdcf2ee8567dd03 100644 --- a/.opencode.json +++ b/.crush.json @@ -1,8 +1,11 @@ { "$schema": "./opencode-schema.json", "lsp": { - "gopls": { + "Go": { "command": "gopls" } + }, + "tui": { + "theme": "charm" } } diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000000000000000000000000000000000..50ad7c0bf32fb7ecabc93f6f580c8e8e64c0dee3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,10 @@ +name: lint +on: + push: + pull_request: + +jobs: + lint: + uses: charmbracelet/meta/.github/workflows/lint.yml@main + with: + golangci_path: .golangci.yml diff --git a/.gitignore b/.gitignore index 36ff9c73267bcc5c7b8ece367108972dad21c1e2..a26fa7ce3a61b69bfba967e503b923bd5e2d71ce 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,6 @@ Thumbs.db .env .env.local -.opencode/ +.crush/ -opencode +crush diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000000000000000000000000000000000000..f1cc201a6202e8777242b9768fe05635e1ab08d3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,44 @@ +version: "2" +linters: + enable: + - bodyclose + # - exhaustive + # - goconst + # - godot + # - godox + - gomoddirectives + - goprintffuncname + # - gosec + - misspell + # - nakedret + # - nestif + # - nilerr + - noctx + - nolintlint + # - prealloc + # - revive + - rowserrcheck + - sqlclosecheck + - tparallel + # - unconvert + # - unparam + - whitespace + # - wrapcheck + disable: + - errcheck + - ineffassign + - staticcheck + - unused + exclusions: + generated: lax + presets: + - common-false-positives +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +formatters: + enable: + - gofumpt + - goimports + exclusions: + generated: lax diff --git a/README.md b/README.md index b98c18301e09c5938bd67a0bb79dd8dbe3abfa1f..c967640fad906c8257784724d4628ea26792beaa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ⌬ OpenCode +# ⌬ Crush

@@ -8,10 +8,10 @@ A powerful terminal-based AI assistant for developers, providing intelligent cod ## Overview -OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more. +Crush is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.

For a quick video overview, check out - OpenCode + Gemini 2.5 Pro: BYE Claude Code! I'm SWITCHING To the FASTEST AI Coder!

+ Crush + Gemini 2.5 Pro: BYE Claude Code! I'm SWITCHING To the FASTEST AI Coder!

@@ -34,45 +34,45 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina ```bash # Install the latest version -curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bash +curl -fsSL https://raw.githubusercontent.com/charmbracelet/crush/refs/heads/main/install | bash # Install a specific version -curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | VERSION=0.1.0 bash +curl -fsSL https://raw.githubusercontent.com/charmbracelet/crush/refs/heads/main/install | VERSION=0.1.0 bash ``` ### Using Homebrew (macOS and Linux) ```bash -brew install opencode-ai/tap/opencode +brew install crush-ai/tap/crush ``` ### Using AUR (Arch Linux) ```bash # Using yay -yay -S opencode-ai-bin +yay -S crush-ai-bin # Using paru -paru -S opencode-ai-bin +paru -S crush-ai-bin ``` ### Using Go ```bash -go install github.com/opencode-ai/opencode@latest +go install github.com/charmbracelet/crush@latest ``` ## Configuration -OpenCode looks for configuration in the following locations: +Crush looks for configuration in the following locations: -- `$HOME/.opencode.json` -- `$XDG_CONFIG_HOME/opencode/.opencode.json` -- `./.opencode.json` (local directory) +- `$HOME/.crush.json` +- `$XDG_CONFIG_HOME/crush/.crush.json` +- `./.crush.json` (local directory) ### Auto Compact Feature -OpenCode includes an auto compact feature that automatically summarizes your conversation when it approaches the model's context window limit. When enabled (default setting), this feature: +Crush includes an auto compact feature that automatically summarizes your conversation when it approaches the model's context window limit. When enabled (default setting), this feature: - Monitors token usage during your conversation - Automatically triggers summarization when usage reaches 95% of the model's context window @@ -89,7 +89,7 @@ You can enable or disable this feature in your configuration file: ### Environment Variables -You can configure OpenCode using environment variables: +You can configure Crush using environment variables: | Environment Variable | Purpose | | -------------------------- | ------------------------------------------------------ | @@ -110,7 +110,7 @@ You can configure OpenCode using environment variables: ### Shell Configuration -OpenCode allows you to configure the shell used by the bash tool. By default, it uses the shell specified in the `SHELL` environment variable, or falls back to `/bin/bash` if not set. +Crush allows you to configure the shell used by the bash tool. By default, it uses the shell specified in the `SHELL` environment variable, or falls back to `/bin/bash` if not set. You can override this in your configuration file: @@ -130,7 +130,7 @@ This is useful if you want to use a different shell than your default system she ```json { "data": { - "directory": ".opencode" + "directory": ".crush" }, "providers": { "openai": { @@ -190,7 +190,7 @@ This is useful if you want to use a different shell than your default system she ## Supported AI Models -OpenCode supports a variety of AI models from different providers: +Crush supports a variety of AI models from different providers: ### OpenAI @@ -247,38 +247,38 @@ OpenCode supports a variety of AI models from different providers: ## Usage ```bash -# Start OpenCode -opencode +# Start Crush +crush # Start with debug logging -opencode -d +crush -d # Start with a specific working directory -opencode -c /path/to/project +crush -c /path/to/project ``` ## Non-interactive Prompt Mode -You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI. +You can run Crush in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI. ```bash # Run a single prompt and print the AI's response to the terminal -opencode -p "Explain the use of context in Go" +crush -p "Explain the use of context in Go" # Get response in JSON format -opencode -p "Explain the use of context in Go" -f json +crush -p "Explain the use of context in Go" -f json # Run without showing the spinner (useful for scripts) -opencode -p "Explain the use of context in Go" -q +crush -p "Explain the use of context in Go" -q ``` -In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session. +In this mode, Crush will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session. -By default, a spinner animation is displayed while the model is processing your query. You can disable this spinner with the `-q` or `--quiet` flag, which is particularly useful when running OpenCode from scripts or automated workflows. +By default, a spinner animation is displayed while the model is processing your query. You can disable this spinner with the `-q` or `--quiet` flag, which is particularly useful when running Crush from scripts or automated workflows. ### Output Formats -OpenCode supports the following output formats in non-interactive mode: +Crush supports the following output formats in non-interactive mode: | Format | Description | | ------ | ------------------------------- | @@ -369,7 +369,7 @@ The output format is implemented as a strongly-typed `OutputFormat` in the codeb ## AI Assistant Tools -OpenCode's AI assistant has access to various tools to help with coding tasks: +Crush's AI assistant has access to various tools to help with coding tasks: ### File and Code Tools @@ -395,7 +395,7 @@ OpenCode's AI assistant has access to various tools to help with coding tasks: ## Architecture -OpenCode is built with a modular architecture: +Crush is built with a modular architecture: - **cmd**: Command-line interface using Cobra - **internal/app**: Core application services @@ -410,7 +410,7 @@ OpenCode is built with a modular architecture: ## Custom Commands -OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant. +Crush supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant. ### Creating Custom Commands @@ -419,26 +419,26 @@ Custom commands are predefined prompts stored as Markdown files in one of three 1. **User Commands** (prefixed with `user:`): ``` - $XDG_CONFIG_HOME/opencode/commands/ + $XDG_CONFIG_HOME/crush/commands/ ``` - (typically `~/.config/opencode/commands/` on Linux/macOS) + (typically `~/.config/crush/commands/` on Linux/macOS) or ``` - $HOME/.opencode/commands/ + $HOME/.crush/commands/ ``` 2. **Project Commands** (prefixed with `project:`): ``` - /.opencode/commands/ + /.crush/commands/ ``` Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID. -For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content: +For example, creating a file at `~/.config/crush/commands/prime-context.md` with content: ```markdown RUN git ls-files @@ -449,7 +449,7 @@ This creates a command called `user:prime-context`. ### Command Arguments -OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter). +Crush supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter). For example: @@ -461,7 +461,7 @@ RUN git grep --author="$AUTHOR_NAME" -n . RUN grep -R "$SEARCH_PATTERN" $DIRECTORY ``` -When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits: +When you run a command with arguments, Crush will prompt you to enter values for each unique placeholder. Named arguments provide several benefits: - Clear identification of what each argument represents - Ability to use the same argument multiple times @@ -472,7 +472,7 @@ When you run a command with arguments, OpenCode will prompt you to enter values You can organize commands in subdirectories: ``` -~/.config/opencode/commands/git/commit.md +~/.config/crush/commands/git/commit.md ``` This creates a command with ID `user:git:commit`. @@ -487,16 +487,16 @@ The content of the command file will be sent as a message to the AI assistant. ### Built-in Commands -OpenCode includes several built-in commands: +Crush includes several built-in commands: | Command | Description | | ------------------ | --------------------------------------------------------------------------------------------------- | -| Initialize Project | Creates or updates the OpenCode.md memory file with project-specific information | +| Initialize Project | Creates or updates the Crush.md memory file with project-specific information | | Compact Session | Manually triggers the summarization of the current session, creating a new session with the summary | ## MCP (Model Context Protocol) -OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools. +Crush implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools. ### MCP Features @@ -537,7 +537,7 @@ Once configured, MCP tools are automatically available to the AI assistant along ## LSP (Language Server Protocol) -OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages. +Crush integrates with Language Server Protocol to provide code intelligence features across multiple programming languages. ### LSP Features @@ -576,13 +576,13 @@ While the LSP client implementation supports the full LSP protocol (including co ## Using a self-hosted model provider -OpenCode can also load and use models from a self-hosted (OpenAI-like) provider. +Crush can also load and use models from a self-hosted (OpenAI-like) provider. This is useful for developers who want to experiment with custom models. ### Configuring a self-hosted provider You can use a self-hosted model by setting the `LOCAL_ENDPOINT` environment variable. -This will cause OpenCode to load and use the models from the specified endpoint. +This will cause Crush to load and use the models from the specified endpoint. ```bash LOCAL_ENDPOINT=http://localhost:1235/v1 @@ -613,19 +613,19 @@ You can also configure a self-hosted model in the configuration file under the ` ```bash # Clone the repository -git clone https://github.com/opencode-ai/opencode.git -cd opencode +git clone https://github.com/charmbracelet/crush.git +cd crush # Build -go build -o opencode +go build -o crush # Run -./opencode +./crush ``` ## Acknowledgments -OpenCode gratefully acknowledges the contributions and support from these key individuals: +Crush gratefully acknowledges the contributions and support from these key individuals: - [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation - [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture @@ -634,7 +634,7 @@ Special thanks to the broader open source community whose tools and libraries ha ## License -OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +Crush is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. ## Contributing diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7e181d84513bc1abc5d5a32807f88a75898633ea --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,46 @@ +# https://taskfile.dev + +version: "3" + +tasks: + lint: + desc: Run base linters + cmds: + - golangci-lint run + + lint-fix: + desc: Run base linters and fix issues + cmds: + - golangci-lint run --fix + + test: + desc: Run tests + cmds: + - go test ./... {{.CLI_ARGS}} + + fmt: + desc: Run gofumpt + cmds: + - gofumpt -w . + + dev: + desc: Run with profiling enabled + env: + CRUSH_PROFILE: true + cmds: + - go run . + + profile:cpu: + desc: 10s CPU profile + cmds: + - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/profile?seconds=10' + + profile:heap: + desc: Heap profile + cmds: + - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/heap' + + profile:allocs: + desc: Allocations profile + cmds: + - go tool pprof -http :6061 'http://localhost:6060/debug/pprof/allocs' diff --git a/cmd/root.go b/cmd/root.go index 3a58cec4ed0914116f5c2a415540f0df8a0f143d..0fb28c958b474dac3093deb94687bea3c9dce1b6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,44 +7,43 @@ import ( "sync" "time" - tea "github.com/charmbracelet/bubbletea" - zone "github.com/lrstanley/bubblezone" - "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/db" - "github.com/opencode-ai/opencode/internal/format" - "github.com/opencode-ai/opencode/internal/llm/agent" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/pubsub" - "github.com/opencode-ai/opencode/internal/tui" - "github.com/opencode-ai/opencode/internal/version" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/format" + "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/tui" + "github.com/charmbracelet/crush/internal/version" "github.com/spf13/cobra" ) var rootCmd = &cobra.Command{ - Use: "opencode", + Use: "crush", Short: "Terminal-based AI assistant for software development", - Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks. + Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks. It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration to assist developers in writing, debugging, and understanding code directly from the terminal.`, Example: ` # Run in interactive mode - opencode + crush # Run with debug logging - opencode -d + crush -d # Run with debug logging in a specific directory - opencode -d -c /path/to/project + crush -d -c /path/to/project # Print version - opencode -v + crush -v # Run a single non-interactive prompt - opencode -p "Explain the use of context in Go" + crush -p "Explain the use of context in Go" # Run a single non-interactive prompt with JSON output format - opencode -p "Explain the use of context in Go" -f json + crush -p "Explain the use of context in Go" -f json `, RunE: func(cmd *cobra.Command, args []string) error { // If the help flag is set, show the help message @@ -114,9 +113,7 @@ to assist developers in writing, debugging, and understanding code directly from return app.RunNonInteractive(ctx, prompt, outputFormat, quiet) } - // Interactive mode // Set up the TUI - zone.NewGlobal() program := tea.NewProgram( tui.New(app), tea.WithAltScreen(), diff --git a/cmd/schema/README.md b/cmd/schema/README.md index b67626635144a4e97b49cf6f5d86808e1a2b2fac..517fdb4d20fb9f2b819051bd72e6c33f5dea2195 100644 --- a/cmd/schema/README.md +++ b/cmd/schema/README.md @@ -1,11 +1,11 @@ -# OpenCode Configuration Schema Generator +# Crush Configuration Schema Generator -This tool generates a JSON Schema for the OpenCode configuration file. The schema can be used to validate configuration files and provide autocompletion in editors that support JSON Schema. +This tool generates a JSON Schema for the Crush configuration file. The schema can be used to validate configuration files and provide autocompletion in editors that support JSON Schema. ## Usage ```bash -go run cmd/schema/main.go > opencode-schema.json +go run cmd/schema/main.go > crush-schema.json ``` This will generate a JSON Schema file that can be used to validate configuration files. @@ -24,7 +24,7 @@ The generated schema includes: You can use the generated schema in several ways: -1. **Editor Integration**: Many editors (VS Code, JetBrains IDEs, etc.) support JSON Schema for validation and autocompletion. You can configure your editor to use the generated schema for `.opencode.json` files. +1. **Editor Integration**: Many editors (VS Code, JetBrains IDEs, etc.) support JSON Schema for validation and autocompletion. You can configure your editor to use the generated schema for `.crush.json` files. 2. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema. @@ -37,7 +37,7 @@ Here's an example configuration that conforms to the schema: ```json { "data": { - "directory": ".opencode" + "directory": ".crush" }, "debug": false, "providers": { diff --git a/cmd/schema/main.go b/cmd/schema/main.go index 429267bc9519f62b1376ecb8cf4089d93a0b09f1..dd49d7256d93fa485d71ad66840f1cbe711e93b0 100644 --- a/cmd/schema/main.go +++ b/cmd/schema/main.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/models" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/models" ) // JSONSchemaType represents a JSON Schema type @@ -38,8 +38,8 @@ func main() { func generateSchema() map[string]any { schema := map[string]any{ "$schema": "http://json-schema.org/draft-07/schema#", - "title": "OpenCode Configuration", - "description": "Configuration schema for the OpenCode application", + "title": "Crush Configuration", + "description": "Configuration schema for the Crush application", "type": "object", "properties": map[string]any{}, } @@ -52,7 +52,7 @@ func generateSchema() map[string]any { "directory": map[string]any{ "type": "string", "description": "Directory where application data is stored", - "default": ".opencode", + "default": ".crush", }, }, "required": []string{"directory"}, @@ -89,12 +89,12 @@ func generateSchema() map[string]any { ".cursor/rules/", "CLAUDE.md", "CLAUDE.local.md", - "opencode.md", - "opencode.local.md", - "OpenCode.md", - "OpenCode.local.md", - "OPENCODE.md", - "OPENCODE.local.md", + "crush.md", + "crush.local.md", + "Crush.md", + "Crush.local.md", + "CRUSH.md", + "CRUSH.local.md", }, } @@ -105,9 +105,9 @@ func generateSchema() map[string]any { "theme": map[string]any{ "type": "string", "description": "TUI theme name", - "default": "opencode", + "default": "crush", "enum": []string{ - "opencode", + "crush", "catppuccin", "dracula", "flexoki", diff --git a/opencode-schema.json b/crush-schema.json similarity index 96% rename from opencode-schema.json rename to crush-schema.json index dc139fda374964b1254d5df12c42751c84d29e7a..16287a73c5931ed3cb50fb90a36aff9746819919 100644 --- a/opencode-schema.json +++ b/crush-schema.json @@ -97,7 +97,7 @@ "type": "object" } }, - "description": "Configuration schema for the OpenCode application", + "description": "Configuration schema for the Crush application", "properties": { "agents": { "additionalProperties": { @@ -216,12 +216,12 @@ ".cursor/rules/", "CLAUDE.md", "CLAUDE.local.md", - "opencode.md", - "opencode.local.md", - "OpenCode.md", - "OpenCode.local.md", - "OPENCODE.md", - "OPENCODE.local.md" + "crush.md", + "crush.local.md", + "Crush.md", + "Crush.local.md", + "CRUSH.md", + "CRUSH.local.md" ], "description": "Context paths for the application", "items": { @@ -233,7 +233,7 @@ "description": "Storage configuration", "properties": { "directory": { - "default": ".opencode", + "default": ".crush", "description": "Directory where application data is stored", "type": "string" } @@ -374,10 +374,10 @@ "description": "Terminal User Interface configuration", "properties": { "theme": { - "default": "opencode", + "default": "crush", "description": "TUI theme name", "enum": [ - "opencode", + "crush", "catppuccin", "dracula", "flexoki", @@ -397,6 +397,6 @@ "type": "string" } }, - "title": "OpenCode Configuration", + "title": "Crush Configuration", "type": "object" } diff --git a/crush.md b/crush.md new file mode 100644 index 0000000000000000000000000000000000000000..d3ddbd6691c5fe6ffcd63c3aca2a36882a4caefc --- /dev/null +++ b/crush.md @@ -0,0 +1,22 @@ +# Crush Development Guide + +## Build/Test/Lint Commands +- **Build**: `go build .` or `go run .` +- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`) +- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix) +- **Format**: `task fmt` (gofumpt -w .) +- **Dev**: `task dev` (runs with profiling enabled) + +## Code Style Guidelines +- **Imports**: Use goimports formatting, group stdlib, external, internal packages +- **Formatting**: Use gofumpt (stricter than gofmt), enabled in golangci-lint +- **Naming**: Standard Go conventions - PascalCase for exported, camelCase for unexported +- **Types**: Prefer explicit types, use type aliases for clarity (e.g., `type AgentName string`) +- **Error handling**: Return errors explicitly, use `fmt.Errorf` for wrapping +- **Context**: Always pass context.Context as first parameter for operations +- **Interfaces**: Define interfaces in consuming packages, keep them small and focused +- **Structs**: Use struct embedding for composition, group related fields +- **Constants**: Use typed constants with iota for enums, group in const blocks +- **Testing**: Use testify/assert and testify/require, parallel tests with `t.Parallel()` +- **JSON tags**: Use snake_case for JSON field names +- **File permissions**: Use octal notation (0o755, 0o644) for file permissions \ No newline at end of file diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000000000000000000000000000000000000..afdb1e5275851972ef8d0cf2c8503fe9f2f26323 --- /dev/null +++ b/cspell.json @@ -0,0 +1 @@ +{"flagWords":[],"language":"en","words":["crush","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph","filepicker","imageorient","rasterx","oksvg","termenv","trashhalo","lucasb","nfnt","srwiley","Lanczos"],"version":"0.2"} \ No newline at end of file diff --git a/go.mod b/go.mod index 82994450a85848a8b1be260b8d1611dec023ac1d..295e588a228ddbf51b640deef9455aef014fb6f3 100644 --- a/go.mod +++ b/go.mod @@ -1,38 +1,50 @@ -module github.com/opencode-ai/opencode +module github.com/charmbracelet/crush go 1.24.0 require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 github.com/JohannesKaufmann/html-to-markdown v1.6.0 + github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.9.2 github.com/alecthomas/chroma/v2 v2.15.0 github.com/anthropics/anthropic-sdk-go v1.4.0 github.com/aymanbagabas/go-udiff v0.2.0 github.com/bmatcuk/doublestar/v4 v4.8.1 - github.com/catppuccin/go v0.3.0 - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.5 - github.com/charmbracelet/glamour v0.9.1 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/ansi v0.8.0 + github.com/charlievieth/fastwalk v1.0.11 + github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94 + github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c + github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 + github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/fsnotify/fsnotify v1.8.0 github.com/go-logfmt/logfmt v0.6.0 github.com/google/uuid v1.6.0 - github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 github.com/mark3labs/mcp-go v0.17.0 - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 - github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.25.0 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/openai/openai-go v0.1.0-beta.2 github.com/pressly/goose/v3 v3.24.2 + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 + github.com/sahilm/fuzzy v0.1.1 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.0 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.10.0 ) +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/disintegration/gift v1.1.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect +) + require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect @@ -56,15 +68,15 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect github.com/aws/smithy-go v1.20.3 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 + github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/windows v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/disintegration/imaging v1.6.2 github.com/dlclark/regexp2 v1.11.4 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -78,10 +90,7 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect - github.com/lithammer/fuzzysearch v1.1.8 github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect @@ -90,7 +99,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect + github.com/rivo/uniseg v0.4.7 github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect @@ -119,7 +128,6 @@ require ( golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect google.golang.org/genai v1.3.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect diff --git a/go.sum b/go.sum index 8b7e307442ad96a69ff3471fe8164a1ba2a46e8a..bc037b574b8bce18c668c507aaec84f2e6cf17f4 100644 --- a/go.sum +++ b/go.sum @@ -66,38 +66,48 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= -github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= -github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM= -github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8= +github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= +github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A= +github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174 h1:TlVW+df0rdU/osP0O8DIVS9WFOAzXe3nuiMwJR4n+CA= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94 h1:QIi50k+uNTJmp2sMs+33D1m/EWr/7OPTJ8x92AY3eOc= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY= +github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ= +github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ= +github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME= +github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 h1:f6tG7ApqIvXTpgF6MZ+C4Ga7669eiW9BsMkXEjDFHfY= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9/go.mod h1:vr+xCFylsPYq2qSz+n5/jItjcK2/PgrKFMTI7VRR6CI= +github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= +github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 h1:/PN4jqP3ki9NvtHRrYZ9ewCutKZB6DK8frTW+Dj/MWs= +github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= +github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86 h1:BxAEmOBIDajkgao3EsbBxKQCYvgYPGdT62WASLvtf4Y= +github.com/charmbracelet/x/input v0.3.5-0.20250509021451-13796e822d86/go.mod h1:62Rp/6EtTxoeJDSdtpA3tJp3y3ZRpsiekBSje+K8htA= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= +github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs= +github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= +github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= +github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -144,31 +154,20 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= -github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms= -github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930= github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I= @@ -177,6 +176,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894= github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= @@ -190,15 +191,18 @@ github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJz github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= @@ -219,9 +223,14 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -273,7 +282,6 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -297,7 +305,6 @@ golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -318,8 +325,6 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -349,6 +354,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= diff --git a/install b/install index b58aa14e23364f702ada60a8db2c0403b9956fec..975bfacd7df000156267e2948cc956af4c991565 100755 --- a/install +++ b/install @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -APP=opencode +APP=crush RED='\033[0;31m' GREEN='\033[0;32m' @@ -36,19 +36,19 @@ case "$filename" in ;; esac -INSTALL_DIR=$HOME/.opencode/bin +INSTALL_DIR=$HOME/.crush/bin mkdir -p "$INSTALL_DIR" if [ -z "$requested_version" ]; then - url="https://github.com/opencode-ai/opencode/releases/latest/download/$filename" - specific_version=$(curl -s https://api.github.com/repos/opencode-ai/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}') + url="https://github.com/charmbracelet/crush/releases/latest/download/$filename" + specific_version=$(curl -s https://api.github.com/repos/charmbracelet/crush/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}') if [[ $? -ne 0 ]]; then echo "${RED}Failed to fetch version information${NC}" exit 1 fi else - url="https://github.com/opencode-ai/opencode/releases/download/v${requested_version}/$filename" + url="https://github.com/charmbracelet/crush/releases/download/v${requested_version}/$filename" specific_version=$requested_version fi @@ -67,12 +67,12 @@ print_message() { } check_version() { - if command -v opencode >/dev/null 2>&1; then - opencode_path=$(which opencode) + if command -v crush >/dev/null 2>&1; then + crush_path=$(which crush) ## TODO: check if version is installed - # installed_version=$(opencode version) + # installed_version=$(crush version) installed_version="0.0.1" installed_version=$(echo $installed_version | awk '{print $2}') @@ -86,11 +86,11 @@ check_version() { } download_and_install() { - print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..." - mkdir -p opencodetmp && cd opencodetmp + print_message info "Downloading ${ORANGE}crush ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..." + mkdir -p crushtmp && cd crushtmp curl -# -L $url | tar xz - mv opencode $INSTALL_DIR - cd .. && rm -rf opencodetmp + mv crush $INSTALL_DIR + cd .. && rm -rf crushtmp } check_version @@ -102,9 +102,9 @@ add_to_path() { local command=$2 if [[ -w $config_file ]]; then - echo -e "\n# opencode" >> "$config_file" + echo -e "\n# crush" >> "$config_file" echo "$command" >> "$config_file" - print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file" + print_message info "Successfully added ${ORANGE}crush ${GREEN}to \$PATH in $config_file" else print_message warning "Manually add the directory to $config_file (or similar):" print_message info " $command" diff --git a/internal/app/app.go b/internal/app/app.go index abdc1431db585694021b66df6490c3f50e41bd64..29c77308111e09f8174ea7f7ceddd30948db8cf1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,17 +9,16 @@ import ( "sync" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/db" - "github.com/opencode-ai/opencode/internal/format" - "github.com/opencode-ai/opencode/internal/history" - "github.com/opencode-ai/opencode/internal/llm/agent" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/permission" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/format" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/session" ) type App struct { @@ -53,9 +52,6 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { LSPClients: make(map[string]*lsp.Client), } - // Initialize theme based on configuration - app.initTheme() - // Initialize LSP clients in the background go app.initLSPClients(ctx) @@ -80,22 +76,6 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { return app, nil } -// initTheme sets the application theme based on the configuration -func (app *App) initTheme() { - cfg := config.Get() - if cfg == nil || cfg.TUI.Theme == "" { - return // Use default theme - } - - // Try to set the theme from config - err := theme.SetTheme(cfg.TUI.Theme) - if err != nil { - logging.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err) - } else { - logging.Debug("Set theme from config", "theme", cfg.TUI.Theme) - } -} - // RunNonInteractive handles the execution flow when a prompt is provided via CLI flag. func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat string, quiet bool) error { logging.Info("Running in non-interactive mode") diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 872532fd80aa6d99adc0e34ee1ecf25de34df253..a056676e1672454adba6d63dd7b7042cc47f6855 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -4,10 +4,10 @@ import ( "context" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/lsp/watcher" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/lsp/watcher" ) func (app *App) initLSPClients(ctx context.Context) { @@ -25,7 +25,7 @@ func (app *App) initLSPClients(ctx context.Context) { func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) { // Create a specific context for initialization with a timeout logging.Info("Creating LSP client", "name", name, "command", command, "args", args) - + // Create the LSP client lspClient, err := lsp.NewClient(ctx, command, args...) if err != nil { @@ -36,7 +36,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman // Create a longer timeout for initialization (some servers take time to start) initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - + // Initialize with the initialization context _, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory()) if err != nil { @@ -57,13 +57,13 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman } logging.Info("LSP client initialized", "name", name) - + // Create a child context that can be canceled when the app is shutting down watchCtx, cancelFunc := context.WithCancel(ctx) - + // Create a context with the server name for better identification watchCtx = context.WithValue(watchCtx, "serverName", name) - + // Create the workspace watcher workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient) diff --git a/internal/completions/files-folders.go b/internal/completions/files-folders.go deleted file mode 100644 index af1b5a8742d7deac39a082c7eba4d3ebf9b303b0..0000000000000000000000000000000000000000 --- a/internal/completions/files-folders.go +++ /dev/null @@ -1,191 +0,0 @@ -package completions - -import ( - "bytes" - "fmt" - "os/exec" - "path/filepath" - - "github.com/lithammer/fuzzysearch/fuzzy" - "github.com/opencode-ai/opencode/internal/fileutil" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/tui/components/dialog" -) - -type filesAndFoldersContextGroup struct { - prefix string -} - -func (cg *filesAndFoldersContextGroup) GetId() string { - return cg.prefix -} - -func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { - return dialog.NewCompletionItem(dialog.CompletionItem{ - Title: "Files & Folders", - Value: "files", - }) -} - -func processNullTerminatedOutput(outputBytes []byte) []string { - if len(outputBytes) > 0 && outputBytes[len(outputBytes)-1] == 0 { - outputBytes = outputBytes[:len(outputBytes)-1] - } - - if len(outputBytes) == 0 { - return []string{} - } - - split := bytes.Split(outputBytes, []byte{0}) - matches := make([]string, 0, len(split)) - - for _, p := range split { - if len(p) == 0 { - continue - } - - path := string(p) - path = filepath.Join(".", path) - - if !fileutil.SkipHidden(path) { - matches = append(matches, path) - } - } - - return matches -} - -func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { - cmdRg := fileutil.GetRgCmd("") // No glob pattern for this use case - cmdFzf := fileutil.GetFzfCmd(query) - - var matches []string - // Case 1: Both rg and fzf available - if cmdRg != nil && cmdFzf != nil { - rgPipe, err := cmdRg.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("failed to get rg stdout pipe: %w", err) - } - defer rgPipe.Close() - - cmdFzf.Stdin = rgPipe - var fzfOut bytes.Buffer - var fzfErr bytes.Buffer - cmdFzf.Stdout = &fzfOut - cmdFzf.Stderr = &fzfErr - - if err := cmdFzf.Start(); err != nil { - return nil, fmt.Errorf("failed to start fzf: %w", err) - } - - errRg := cmdRg.Run() - errFzf := cmdFzf.Wait() - - if errRg != nil { - logging.Warn(fmt.Sprintf("rg command failed during pipe: %v", errRg)) - } - - if errFzf != nil { - if exitErr, ok := errFzf.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return []string{}, nil // No matches from fzf - } - return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", errFzf, fzfErr.String()) - } - - matches = processNullTerminatedOutput(fzfOut.Bytes()) - - // Case 2: Only rg available - } else if cmdRg != nil { - logging.Debug("Using Ripgrep with fuzzy match fallback for file completions") - var rgOut bytes.Buffer - var rgErr bytes.Buffer - cmdRg.Stdout = &rgOut - cmdRg.Stderr = &rgErr - - if err := cmdRg.Run(); err != nil { - return nil, fmt.Errorf("rg command failed: %w\nStderr: %s", err, rgErr.String()) - } - - allFiles := processNullTerminatedOutput(rgOut.Bytes()) - matches = fuzzy.Find(query, allFiles) - - // Case 3: Only fzf available - } else if cmdFzf != nil { - logging.Debug("Using FZF with doublestar fallback for file completions") - files, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) - if err != nil { - return nil, fmt.Errorf("failed to list files for fzf: %w", err) - } - - allFiles := make([]string, 0, len(files)) - for _, file := range files { - if !fileutil.SkipHidden(file) { - allFiles = append(allFiles, file) - } - } - - var fzfIn bytes.Buffer - for _, file := range allFiles { - fzfIn.WriteString(file) - fzfIn.WriteByte(0) - } - - cmdFzf.Stdin = &fzfIn - var fzfOut bytes.Buffer - var fzfErr bytes.Buffer - cmdFzf.Stdout = &fzfOut - cmdFzf.Stderr = &fzfErr - - if err := cmdFzf.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return []string{}, nil - } - return nil, fmt.Errorf("fzf command failed: %w\nStderr: %s", err, fzfErr.String()) - } - - matches = processNullTerminatedOutput(fzfOut.Bytes()) - - // Case 4: Fallback to doublestar with fuzzy match - } else { - logging.Debug("Using doublestar with fuzzy match for file completions") - allFiles, _, err := fileutil.GlobWithDoublestar("**/*", ".", 0) - if err != nil { - return nil, fmt.Errorf("failed to glob files: %w", err) - } - - filteredFiles := make([]string, 0, len(allFiles)) - for _, file := range allFiles { - if !fileutil.SkipHidden(file) { - filteredFiles = append(filteredFiles, file) - } - } - - matches = fuzzy.Find(query, filteredFiles) - } - - return matches, nil -} - -func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { - matches, err := cg.getFiles(query) - if err != nil { - return nil, err - } - - items := make([]dialog.CompletionItemI, 0, len(matches)) - for _, file := range matches { - item := dialog.NewCompletionItem(dialog.CompletionItem{ - Title: file, - Value: file, - }) - items = append(items, item) - } - - return items, nil -} - -func NewFileAndFolderContextGroup() dialog.CompletionProvider { - return &filesAndFoldersContextGroup{ - prefix: "file", - } -} diff --git a/internal/config/config.go b/internal/config/config.go index 5a0905bba239c0d7c79f669801ef9b3a5caa9cf9..1d7786a1dd2eef536ee0e014b47c93ecbd50fa9d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/logging" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/logging" "github.com/spf13/viper" ) @@ -97,9 +97,9 @@ type Config struct { // Application constants const ( - defaultDataDirectory = ".opencode" + defaultDataDirectory = ".crush" defaultLogLevel = "info" - appName = "opencode" + appName = "crush" MaxTokensFallbackDefault = 4096 ) @@ -110,12 +110,12 @@ var defaultContextPaths = []string{ ".cursor/rules/", "CLAUDE.md", "CLAUDE.local.md", - "opencode.md", - "opencode.local.md", - "OpenCode.md", - "OpenCode.local.md", - "OPENCODE.md", - "OPENCODE.local.md", + "crush.md", + "crush.local.md", + "Crush.md", + "Crush.local.md", + "CRUSH.md", + "CRUSH.local.md", } // Global configuration instance @@ -159,7 +159,7 @@ func Load(workingDir string, debug bool) (*Config, error) { if cfg.Debug { defaultLevel = slog.LevelDebug } - if os.Getenv("OPENCODE_DEV_DEBUG") == "true" { + if os.Getenv("CRUSH_DEV_DEBUG") == "true" { loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log") // if file does not exist create it @@ -221,7 +221,7 @@ func configureViper() { func setDefaults(debug bool) { viper.SetDefault("data.directory", defaultDataDirectory) viper.SetDefault("contextPaths", defaultContextPaths) - viper.SetDefault("tui.theme", "opencode") + viper.SetDefault("tui.theme", "crush") viper.SetDefault("autoCompact", true) // Set default shell from environment or fallback to /bin/bash diff --git a/internal/config/init.go b/internal/config/init.go index e0a1c6da7372fb3c66656d18bdf565357b6b1b07..df9f213f15ddd4e85f912e3c121276a7da28ac09 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) const ( @@ -37,10 +38,41 @@ func ShouldShowInitDialog() (bool, error) { return false, fmt.Errorf("failed to check init flag file: %w", err) } + // Check if any variation of crush.md already exists in working directory + crushExists, err := crushMdExists(WorkingDirectory()) + if err != nil { + return false, fmt.Errorf("failed to check for crush.md files: %w", err) + } + if crushExists { + // Crush.md already exists, don't show the dialog + return false, nil + } + // File doesn't exist, show the dialog return true, nil } +// crushMdExists checks if any case variation of crush.md exists in the directory +func crushMdExists(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := strings.ToLower(entry.Name()) + if name == "crush.md" { + return true, nil + } + } + + return false, nil +} + // MarkProjectInitialized marks the current project as initialized func MarkProjectInitialized() error { if cfg == nil { @@ -58,4 +90,3 @@ func MarkProjectInitialized() error { return nil } - diff --git a/internal/db/connect.go b/internal/db/connect.go index b8fcb736261adc9b5e6c06cd02a8364eec87acea..ed48ddcba8fea094c815b009dcaa5ce1cc354d0c 100644 --- a/internal/db/connect.go +++ b/internal/db/connect.go @@ -9,8 +9,8 @@ import ( _ "github.com/ncruces/go-sqlite3/driver" _ "github.com/ncruces/go-sqlite3/embed" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/logging" "github.com/pressly/goose/v3" ) @@ -23,7 +23,7 @@ func Connect() (*sql.DB, error) { if err := os.MkdirAll(dataDir, 0o700); err != nil { return nil, fmt.Errorf("failed to create data directory: %w", err) } - dbPath := filepath.Join(dataDir, "opencode.db") + dbPath := filepath.Join(dataDir, "crush.db") // Open the SQLite database db, err := sql.Open("sqlite3", dbPath) if err != nil { diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 8f5e669d3c2c6741ea6facaab3f447283ed5a5ab..89087ecc7594c6feab222c5b7ea288db1ac112e4 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -1,22 +1,18 @@ package diff import ( - "bytes" "fmt" - "io" + "image/color" "regexp" "strconv" "strings" - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/formatters" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/alecthomas/chroma/v2/styles" "github.com/aymanbagabas/go-udiff" - "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/highlight" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/sergi/go-diff/diffmatchpatch" ) @@ -236,10 +232,7 @@ func HighlightIntralineChanges(h *Hunk) { for i := 0; i < len(h.Lines); i++ { // Look for removed line followed by added line - if i+1 < len(h.Lines) && - h.Lines[i].Kind == LineRemoved && - h.Lines[i+1].Kind == LineAdded { - + if i+1 < len(h.Lines) && h.Lines[i].Kind == LineRemoved && h.Lines[i+1].Kind == LineAdded { oldLine := h.Lines[i] newLine := h.Lines[i+1] @@ -321,241 +314,26 @@ func pairLines(lines []DiffLine) []linePair { // ------------------------------------------------------------------------- // Syntax Highlighting // ------------------------------------------------------------------------- - -// SyntaxHighlight applies syntax highlighting to text based on file extension -func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error { - t := theme.CurrentTheme() - - // Determine the language lexer to use - l := lexers.Match(fileName) - if l == nil { - l = lexers.Analyse(source) - } - if l == nil { - l = lexers.Fallback - } - l = chroma.Coalesce(l) - - // Get the formatter - f := formatters.Get(formatter) - if f == nil { - f = formatters.Fallback - } - - // Dynamic theme based on current theme values - syntaxThemeXml := fmt.Sprintf(` - -`, - getColor(t.Background()), // Background - getColor(t.Text()), // Text - getColor(t.Text()), // Other - getColor(t.Error()), // Error - - getColor(t.SyntaxKeyword()), // Keyword - getColor(t.SyntaxKeyword()), // KeywordConstant - getColor(t.SyntaxKeyword()), // KeywordDeclaration - getColor(t.SyntaxKeyword()), // KeywordNamespace - getColor(t.SyntaxKeyword()), // KeywordPseudo - getColor(t.SyntaxKeyword()), // KeywordReserved - getColor(t.SyntaxType()), // KeywordType - - getColor(t.Text()), // Name - getColor(t.SyntaxVariable()), // NameAttribute - getColor(t.SyntaxType()), // NameBuiltin - getColor(t.SyntaxVariable()), // NameBuiltinPseudo - getColor(t.SyntaxType()), // NameClass - getColor(t.SyntaxVariable()), // NameConstant - getColor(t.SyntaxFunction()), // NameDecorator - getColor(t.SyntaxVariable()), // NameEntity - getColor(t.SyntaxType()), // NameException - getColor(t.SyntaxFunction()), // NameFunction - getColor(t.Text()), // NameLabel - getColor(t.SyntaxType()), // NameNamespace - getColor(t.SyntaxVariable()), // NameOther - getColor(t.SyntaxKeyword()), // NameTag - getColor(t.SyntaxVariable()), // NameVariable - getColor(t.SyntaxVariable()), // NameVariableClass - getColor(t.SyntaxVariable()), // NameVariableGlobal - getColor(t.SyntaxVariable()), // NameVariableInstance - - getColor(t.SyntaxString()), // Literal - getColor(t.SyntaxString()), // LiteralDate - getColor(t.SyntaxString()), // LiteralString - getColor(t.SyntaxString()), // LiteralStringBacktick - getColor(t.SyntaxString()), // LiteralStringChar - getColor(t.SyntaxString()), // LiteralStringDoc - getColor(t.SyntaxString()), // LiteralStringDouble - getColor(t.SyntaxString()), // LiteralStringEscape - getColor(t.SyntaxString()), // LiteralStringHeredoc - getColor(t.SyntaxString()), // LiteralStringInterpol - getColor(t.SyntaxString()), // LiteralStringOther - getColor(t.SyntaxString()), // LiteralStringRegex - getColor(t.SyntaxString()), // LiteralStringSingle - getColor(t.SyntaxString()), // LiteralStringSymbol - - getColor(t.SyntaxNumber()), // LiteralNumber - getColor(t.SyntaxNumber()), // LiteralNumberBin - getColor(t.SyntaxNumber()), // LiteralNumberFloat - getColor(t.SyntaxNumber()), // LiteralNumberHex - getColor(t.SyntaxNumber()), // LiteralNumberInteger - getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong - getColor(t.SyntaxNumber()), // LiteralNumberOct - - getColor(t.SyntaxOperator()), // Operator - getColor(t.SyntaxKeyword()), // OperatorWord - getColor(t.SyntaxPunctuation()), // Punctuation - - getColor(t.SyntaxComment()), // Comment - getColor(t.SyntaxComment()), // CommentHashbang - getColor(t.SyntaxComment()), // CommentMultiline - getColor(t.SyntaxComment()), // CommentSingle - getColor(t.SyntaxComment()), // CommentSpecial - getColor(t.SyntaxKeyword()), // CommentPreproc - - getColor(t.Text()), // Generic - getColor(t.Error()), // GenericDeleted - getColor(t.Text()), // GenericEmph - getColor(t.Error()), // GenericError - getColor(t.Text()), // GenericHeading - getColor(t.Success()), // GenericInserted - getColor(t.TextMuted()), // GenericOutput - getColor(t.Text()), // GenericPrompt - getColor(t.Text()), // GenericStrong - getColor(t.Text()), // GenericSubheading - getColor(t.Error()), // GenericTraceback - getColor(t.Text()), // TextWhitespace - ) - - r := strings.NewReader(syntaxThemeXml) - style := chroma.MustNewXMLStyle(r) - - // Modify the style to use the provided background - s, err := style.Builder().Transform( - func(t chroma.StyleEntry) chroma.StyleEntry { - r, g, b, _ := bg.RGBA() - t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8)) - return t - }, - ).Build() - if err != nil { - s = styles.Fallback - } - - // Tokenize and format - it, err := l.Tokenise(nil, source) - if err != nil { - return err - } - - return f.Format(w, s, it) -} - -// getColor returns the appropriate hex color string based on terminal background -func getColor(adaptiveColor lipgloss.AdaptiveColor) string { - if lipgloss.HasDarkBackground() { - return adaptiveColor.Dark - } - return adaptiveColor.Light +func getColor(c color.Color) string { + rgba := color.RGBAModel.Convert(c).(color.RGBA) + return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) } // highlightLine applies syntax highlighting to a single line -func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string { - var buf bytes.Buffer - err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg) +func highlightLine(fileName string, line string, bg color.Color) string { + highlighted, err := highlight.SyntaxHighlight(line, fileName, bg) if err != nil { return line } - return buf.String() + return highlighted } // createStyles generates the lipgloss styles needed for rendering diffs -func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) { - removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg()) - addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg()) - contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg()) - lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber()) - +func createStyles(t *styles.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) { + removedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.RemovedBg) + addedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.AddedBg) + contextLineStyle = lipgloss.NewStyle().Background(t.S().Diff.ContextBg) + lineNumberStyle = lipgloss.NewStyle().Foreground(t.S().Diff.LineNumber) return } @@ -563,20 +341,8 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS // Rendering Functions // ------------------------------------------------------------------------- -func lipglossToHex(color lipgloss.Color) string { - r, g, b, a := color.RGBA() - - // Scale uint32 values (0-65535) to uint8 (0-255). - r8 := uint8(r >> 8) - g8 := uint8(g >> 8) - b8 := uint8(b >> 8) - a8 := uint8(a >> 8) - - return fmt.Sprintf("#%02x%02x%02x%02x", r8, g8, b8, a8) -} - // applyHighlighting applies intra-line highlighting to a piece of text -func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string { +func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string { // Find all ANSI sequences in the content ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`) ansiMatches := ansiRegex.FindAllStringIndex(content, -1) @@ -616,7 +382,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType, // Get the appropriate color based on terminal background bgColor := lipgloss.Color(getColor(highlightBg)) - fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background())) + // fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background())) for i := 0; i < len(content); { // Check if we're at an ANSI sequence @@ -653,15 +419,15 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType, currentStyle := ansiSequences[currentPos] // Apply foreground and background highlight - sb.WriteString("\x1b[38;2;") - r, g, b, _ := fgColor.RGBA() - sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) + // sb.WriteString("\x1b[38;2;") + // r, g, b, _ := fgColor.RGBA() + // sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) sb.WriteString("\x1b[48;2;") - r, g, b, _ = bgColor.RGBA() + r, g, b, _ := bgColor.RGBA() sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) sb.WriteString(char) // Reset foreground and background - sb.WriteString("\x1b[39m") + // sb.WriteString("\x1b[39m") // Reapply the original ANSI sequence sb.WriteString(currentStyle) @@ -679,10 +445,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType, // renderLeftColumn formats the left side of a side-by-side diff func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string { - t := theme.CurrentTheme() + t := styles.CurrentTheme() if dl == nil { - contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg()) + contextLineStyle := t.S().Base.Background(t.S().Diff.ContextBg) return contextLineStyle.Width(colWidth).Render("") } @@ -693,9 +459,9 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string { var bgStyle lipgloss.Style switch dl.Kind { case LineRemoved: - marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-") + marker = removedLineStyle.Foreground(t.S().Diff.Removed).Render("-") bgStyle = removedLineStyle - lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg()) + lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Removed).Background(t.S().Diff.RemovedLineNumberBg) case LineAdded: marker = "?" bgStyle = contextLineStyle @@ -718,7 +484,7 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string { // Apply intra-line highlighting for removed lines if dl.Kind == LineRemoved && len(dl.Segments) > 0 { - content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved()) + content = applyHighlighting(content, dl.Segments, LineRemoved, t.S().Diff.HighlightRemoved) } // Add a padding space for removed lines @@ -732,17 +498,17 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string { ansi.Truncate( lineText, colWidth, - lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."), + lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."), ), ) } // renderRightColumn formats the right side of a side-by-side diff func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string { - t := theme.CurrentTheme() + t := styles.CurrentTheme() if dl == nil { - contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg()) + contextLineStyle := lipgloss.NewStyle().Background(t.S().Diff.ContextBg) return contextLineStyle.Width(colWidth).Render("") } @@ -753,9 +519,9 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string { var bgStyle lipgloss.Style switch dl.Kind { case LineAdded: - marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+") + marker = addedLineStyle.Foreground(t.S().Diff.Added).Render("+") bgStyle = addedLineStyle - lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg()) + lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Added).Background(t.S().Diff.AddedLineNumberBg) case LineRemoved: marker = "?" bgStyle = contextLineStyle @@ -778,7 +544,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string { // Apply intra-line highlighting for added lines if dl.Kind == LineAdded && len(dl.Segments) > 0 { - content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded()) + content = applyHighlighting(content, dl.Segments, LineAdded, t.S().Diff.HighlightAdded) } // Add a padding space for added lines @@ -792,7 +558,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string { ansi.Truncate( lineText, colWidth, - lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."), + lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."), ), ) } diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index 1883f1853db8aa414e1ca0b392c7e7f858d7f068..92fc9d39c585f7784c7fe8ca21a0cf8d6958cbcb 100644 --- a/internal/fileutil/fileutil.go +++ b/internal/fileutil/fileutil.go @@ -2,7 +2,6 @@ package fileutil import ( "fmt" - "io/fs" "os" "os/exec" "path/filepath" @@ -11,7 +10,9 @@ import ( "time" "github.com/bmatcuk/doublestar/v4" - "github.com/opencode-ai/opencode/internal/logging" + "github.com/charlievieth/fastwalk" + "github.com/charmbracelet/crush/internal/logging" + ignore "github.com/sabhiram/go-gitignore" ) var ( @@ -53,21 +54,6 @@ func GetRgCmd(globPattern string) *exec.Cmd { return cmd } -func GetFzfCmd(query string) *exec.Cmd { - if fzfPath == "" { - return nil - } - fzfArgs := []string{ - "--filter", - query, - "--read0", - "--print0", - } - cmd := exec.Command(fzfPath, fzfArgs...) - cmd.Dir = "." - return cmd -} - type FileInfo struct { Path string ModTime time.Time @@ -81,7 +67,7 @@ func SkipHidden(path string) bool { } commonIgnoredDirs := map[string]bool{ - ".opencode": true, + ".crush": true, "node_modules": true, "vendor": true, "dist": true, @@ -112,37 +98,92 @@ func SkipHidden(path string) bool { return false } -func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) { - fsys := os.DirFS(searchPath) - relPattern := strings.TrimPrefix(pattern, "/") +// FastGlobWalker provides gitignore-aware file walking with fastwalk +type FastGlobWalker struct { + gitignore *ignore.GitIgnore + rootPath string +} + +func NewFastGlobWalker(searchPath string) *FastGlobWalker { + walker := &FastGlobWalker{ + rootPath: searchPath, + } + + // Load gitignore if it exists + gitignorePath := filepath.Join(searchPath, ".gitignore") + if _, err := os.Stat(gitignorePath); err == nil { + if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil { + walker.gitignore = gi + } + } + + return walker +} + +func (w *FastGlobWalker) shouldSkip(path string) bool { + if SkipHidden(path) { + return true + } + + if w.gitignore != nil { + relPath, err := filepath.Rel(w.rootPath, path) + if err == nil && w.gitignore.MatchesPath(relPath) { + return true + } + } + + return false +} + +func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) { + walker := NewFastGlobWalker(searchPath) var matches []FileInfo + conf := fastwalk.Config{ + Follow: true, + // Use forward slashes when running a Windows binary under WSL or MSYS + ToSlash: fastwalk.DefaultToSlash(), + Sort: fastwalk.SortFilesFirst, + } + err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // Skip files we can't access + } - err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error { if d.IsDir() { + if walker.shouldSkip(path) { + return filepath.SkipDir + } return nil } - if SkipHidden(path) { + + if walker.shouldSkip(path) { return nil } - info, err := d.Info() + + // Check if path matches the pattern + relPath, err := filepath.Rel(searchPath, path) if err != nil { + relPath = path + } + + matched, err := doublestar.Match(pattern, relPath) + if err != nil || !matched { return nil } - absPath := path - if !strings.HasPrefix(absPath, searchPath) && searchPath != "." { - absPath = filepath.Join(searchPath, absPath) - } else if !strings.HasPrefix(absPath, "/") && searchPath == "." { - absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly + + info, err := d.Info() + if err != nil { + return nil } - matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()}) + matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()}) if limit > 0 && len(matches) >= limit*2 { - return fs.SkipAll + return filepath.SkipAll } return nil }) if err != nil { - return nil, false, fmt.Errorf("glob walk error: %w", err) + return nil, false, fmt.Errorf("fastwalk error: %w", err) } sort.Slice(matches, func(i, j int) bool { @@ -161,3 +202,12 @@ func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, } return results, truncated, nil } + +func PrettyPath(path string) string { + // replace home directory with ~ + homeDir, err := os.UserHomeDir() + if err == nil { + path = strings.ReplaceAll(path, homeDir, "~") + } + return path +} diff --git a/internal/fileutil/ls.go b/internal/fileutil/ls.go new file mode 100644 index 0000000000000000000000000000000000000000..1c898a642a82b0b0500d354721a06f18876c4da0 --- /dev/null +++ b/internal/fileutil/ls.go @@ -0,0 +1,169 @@ +package fileutil + +import ( + "os" + "path/filepath" + "strings" + + "github.com/charlievieth/fastwalk" + ignore "github.com/sabhiram/go-gitignore" +) + +// CommonIgnorePatterns contains commonly ignored files and directories +var CommonIgnorePatterns = []string{ + // Version control + ".git", + ".svn", + ".hg", + ".bzr", + + // IDE and editor files + ".vscode", + ".idea", + "*.swp", + "*.swo", + "*~", + ".DS_Store", + "Thumbs.db", + + // Build artifacts and dependencies + "node_modules", + "target", + "build", + "dist", + "out", + "bin", + "obj", + "*.o", + "*.so", + "*.dylib", + "*.dll", + "*.exe", + + // Logs and temporary files + "*.log", + "*.tmp", + "*.temp", + ".cache", + ".tmp", + + // Language-specific + "__pycache__", + "*.pyc", + "*.pyo", + ".pytest_cache", + "vendor", + "Cargo.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + + // OS generated files + ".Trash", + ".Spotlight-V100", + ".fseventsd", + + // Crush + ".crush", +} + +type DirectoryLister struct { + gitignore *ignore.GitIgnore + commonIgnore *ignore.GitIgnore + rootPath string +} + +func NewDirectoryLister(rootPath string) *DirectoryLister { + dl := &DirectoryLister{ + rootPath: rootPath, + } + + // Load gitignore if it exists + gitignorePath := filepath.Join(rootPath, ".gitignore") + if _, err := os.Stat(gitignorePath); err == nil { + if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil { + dl.gitignore = gi + } + } + + // Create common ignore patterns + dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...) + + return dl +} + +func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool { + relPath, err := filepath.Rel(dl.rootPath, path) + if err != nil { + relPath = path + } + + // Check common ignore patterns + if dl.commonIgnore.MatchesPath(relPath) { + return true + } + + // Check gitignore patterns if available + if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) { + return true + } + + base := filepath.Base(path) + + if base != "." && strings.HasPrefix(base, ".") { + return true + } + + for _, pattern := range ignorePatterns { + matched, err := filepath.Match(pattern, base) + if err == nil && matched { + return true + } + } + return false +} + +// ListDirectory lists files and directories in the specified path, +func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) { + var results []string + truncated := false + dl := NewDirectoryLister(initialPath) + + conf := fastwalk.Config{ + Follow: true, + // Use forward slashes when running a Windows binary under WSL or MSYS + ToSlash: fastwalk.DefaultToSlash(), + Sort: fastwalk.SortDirsFirst, + } + err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // Skip files we don't have permission to access + } + + if dl.shouldIgnore(path, ignorePatterns) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + if path != initialPath { + if d.IsDir() { + path = path + string(filepath.Separator) + } + results = append(results, path) + } + + if limit > 0 && len(results) >= limit { + truncated = true + return filepath.SkipAll + } + + return nil + }) + if err != nil { + return nil, truncated, err + } + + return results, truncated, nil +} diff --git a/internal/format/spinner.go b/internal/format/spinner.go index 083ee557f82ea903fb9d1d7be5da1da120744f6c..739fac1b27c99b9c4c4da030943500eda79f957c 100644 --- a/internal/format/spinner.go +++ b/internal/format/spinner.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/v2/spinner" + tea "github.com/charmbracelet/bubbletea/v2" ) // Spinner wraps the bubbles spinner for non-interactive mode @@ -31,7 +31,7 @@ func (m spinnerModel) Init() tea.Cmd { func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: m.quitting = true return m, tea.Quit case spinner.TickMsg: diff --git a/internal/highlight/highlight.go b/internal/highlight/highlight.go new file mode 100644 index 0000000000000000000000000000000000000000..3b9c7643cf0d6b281c5eb66523cb97ee4197faf6 --- /dev/null +++ b/internal/highlight/highlight.go @@ -0,0 +1,60 @@ +package highlight + +import ( + "bytes" + "fmt" + "image/color" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + chromaStyles "github.com/alecthomas/chroma/v2/styles" + "github.com/charmbracelet/crush/internal/tui/styles" +) + +func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) { + // Determine the language lexer to use + l := lexers.Match(fileName) + if l == nil { + l = lexers.Analyse(source) + } + if l == nil { + l = lexers.Fallback + } + l = chroma.Coalesce(l) + + // Get the formatter + f := formatters.Get("terminal16m") + if f == nil { + f = formatters.Fallback + } + + style := chroma.MustNewStyle("crush", styles.GetChromaTheme()) + + // Modify the style to use the provided background + s, err := style.Builder().Transform( + func(t chroma.StyleEntry) chroma.StyleEntry { + r, g, b, _ := bg.RGBA() + t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + return t + }, + ).Build() + if err != nil { + s = chromaStyles.Fallback + } + + // Tokenize and format + it, err := l.Tokenise(nil, source) + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = f.Format(&buf, s, it) + return buf.String(), err +} + +func getColor(c color.Color) string { + rgba := color.RGBAModel.Convert(c).(color.RGBA) + return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) +} diff --git a/internal/history/file.go b/internal/history/file.go index 9cdb2e47b2736b1800232f853682c125533d97e1..cf1b92bd436f93e49757dfe1ee6b8cddeef891d3 100644 --- a/internal/history/file.go +++ b/internal/history/file.go @@ -8,9 +8,9 @@ import ( "strings" "time" + "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/google/uuid" - "github.com/opencode-ai/opencode/internal/db" - "github.com/opencode-ai/opencode/internal/pubsub" ) const ( diff --git a/internal/llm/agent/agent-tool.go b/internal/llm/agent/agent-tool.go index 781720ded69e625bed44eb5baa30b879b28e94ca..de4a86ac36d62ef0990a58d6abeb9a53572bc215 100644 --- a/internal/llm/agent/agent-tool.go +++ b/internal/llm/agent/agent-tool.go @@ -5,11 +5,11 @@ import ( "encoding/json" "fmt" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/session" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/session" ) type agentTool struct { diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 4f31fe75d688aa2c4fdd80a4f633fe35d45125cc..c19451e1d0ef46597b8e3f9d56f9e0ebdf4362cb 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -8,16 +8,16 @@ import ( "sync" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/llm/prompt" - "github.com/opencode-ai/opencode/internal/llm/provider" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/permission" - "github.com/opencode-ai/opencode/internal/pubsub" - "github.com/opencode-ai/opencode/internal/session" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/llm/prompt" + "github.com/charmbracelet/crush/internal/llm/provider" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/session" ) // Common errors @@ -443,18 +443,14 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg assistantMsg.AppendContent(event.Content) return a.messages.Update(ctx, *assistantMsg) case provider.EventToolUseStart: + logging.Info("Tool call started", "toolCall", event.ToolCall) assistantMsg.AddToolCall(*event.ToolCall) return a.messages.Update(ctx, *assistantMsg) - // TODO: see how to handle this - // case provider.EventToolUseDelta: - // tm := time.Unix(assistantMsg.UpdatedAt, 0) - // assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input) - // if time.Since(tm) > 1000*time.Millisecond { - // err := a.messages.Update(ctx, *assistantMsg) - // assistantMsg.UpdatedAt = time.Now().Unix() - // return err - // } + case provider.EventToolUseDelta: + assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input) + return a.messages.Update(ctx, *assistantMsg) case provider.EventToolUseStop: + logging.Info("Finished tool call", "toolCall", event.ToolCall) assistantMsg.FinishToolCall(event.ToolCall.ID) return a.messages.Update(ctx, *assistantMsg) case provider.EventError: @@ -590,22 +586,26 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error { a.Publish(pubsub.CreatedEvent, event) // Send the messages to the summarize provider - response, err := a.summarizeProvider.SendMessages( + response := a.summarizeProvider.StreamResponse( summarizeCtx, msgsWithPrompt, make([]tools.BaseTool, 0), ) - if err != nil { - event = AgentEvent{ - Type: AgentEventTypeError, - Error: fmt.Errorf("failed to summarize: %w", err), - Done: true, + var finalResponse *provider.ProviderResponse + for r := range response { + if r.Error != nil { + event = AgentEvent{ + Type: AgentEventTypeError, + Error: fmt.Errorf("failed to summarize: %w", err), + Done: true, + } + a.Publish(pubsub.CreatedEvent, event) + return } - a.Publish(pubsub.CreatedEvent, event) - return + finalResponse = r.Response } - summary := strings.TrimSpace(response.Content) + summary := strings.TrimSpace(finalResponse.Content) if summary == "" { event = AgentEvent{ Type: AgentEventTypeError, @@ -655,10 +655,10 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error { return } oldSession.SummaryMessageID = msg.ID - oldSession.CompletionTokens = response.Usage.OutputTokens + oldSession.CompletionTokens = finalResponse.Usage.OutputTokens oldSession.PromptTokens = 0 model := a.summarizeProvider.Model() - usage := response.Usage + usage := finalResponse.Usage cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) + model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) + model.CostPer1MIn/1e6*float64(usage.InputTokens) + diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 2375606416e144db5ada7b0ab4309c7987aa8080..55b6983d053a70ccebf56f7c6d239246acf8c317 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -5,11 +5,11 @@ import ( "encoding/json" "fmt" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/permission" - "github.com/opencode-ai/opencode/internal/version" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/version" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" @@ -46,7 +46,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t initRequest := mcp.InitializeRequest{} initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initRequest.Params.ClientInfo = mcp.Implementation{ - Name: "OpenCode", + Name: "Crush", Version: version.Version, } @@ -140,7 +140,7 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions initRequest := mcp.InitializeRequest{} initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initRequest.Params.ClientInfo = mcp.Implementation{ - Name: "OpenCode", + Name: "Crush", Version: version.Version, } diff --git a/internal/llm/agent/tools.go b/internal/llm/agent/tools.go index e6b0119aef3e9ebc0cf9fe12c9c4d45767245aa8..763f53ea6f2246f2acae3f8c2907abf8be34a1d0 100644 --- a/internal/llm/agent/tools.go +++ b/internal/llm/agent/tools.go @@ -3,12 +3,12 @@ package agent import ( "context" - "github.com/opencode-ai/opencode/internal/history" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/permission" - "github.com/opencode-ai/opencode/internal/session" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/session" ) func CoderAgentTools( diff --git a/internal/llm/models/local.go b/internal/llm/models/local.go index 5d8412c86a0f3f4ccf305763171f6acfdaea6eb1..3a50fdf48fe86167600eceee3cce26b6caac900e 100644 --- a/internal/llm/models/local.go +++ b/internal/llm/models/local.go @@ -2,6 +2,7 @@ package models import ( "cmp" + "context" "encoding/json" "net/http" "net/url" @@ -10,7 +11,7 @@ import ( "strings" "unicode" - "github.com/opencode-ai/opencode/internal/logging" + "github.com/charmbracelet/crush/internal/logging" "github.com/spf13/viper" ) @@ -53,7 +54,6 @@ func init() { loadLocalModels(models) viper.SetDefault("providers.local.apiKey", "dummy") - ProviderPopularity[ProviderLocal] = 0 } } @@ -75,7 +75,7 @@ type localModel struct { } func listLocalModels(modelsEndpoint string) []localModel { - res, err := http.Get(modelsEndpoint) + res, err := http.NewRequestWithContext(context.Background(), http.MethodGet, modelsEndpoint, nil) if err != nil { logging.Debug("Failed to list local models", "error", err, @@ -84,9 +84,9 @@ func listLocalModels(modelsEndpoint string) []localModel { } defer res.Body.Close() - if res.StatusCode != http.StatusOK { + if res.Response.StatusCode != http.StatusOK { logging.Debug("Failed to list local models", - "status", res.StatusCode, + "status", res.Response.Status, "endpoint", modelsEndpoint, ) } diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index 47d217184de54f7e2937286cd2c64c9e98c4a02b..50e8723989ccb268a9f515b4c693662654fa38d5 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -34,44 +34,8 @@ const ( ProviderMock ModelProvider = "__mock" ) -// Providers in order of popularity -var ProviderPopularity = map[ModelProvider]int{ - ProviderAnthropic: 1, - ProviderOpenAI: 2, - ProviderGemini: 3, - ProviderGROQ: 4, - ProviderOpenRouter: 5, - ProviderBedrock: 6, - ProviderAzure: 7, - ProviderVertexAI: 8, -} - var SupportedModels = map[ModelID]Model{ - // - // // GEMINI - // GEMINI25: { - // ID: GEMINI25, - // Name: "Gemini 2.5 Pro", - // Provider: ProviderGemini, - // APIModel: "gemini-2.5-pro-exp-03-25", - // CostPer1MIn: 0, - // CostPer1MInCached: 0, - // CostPer1MOutCached: 0, - // CostPer1MOut: 0, - // }, - // - // GRMINI20Flash: { - // ID: GRMINI20Flash, - // Name: "Gemini 2.0 Flash", - // Provider: ProviderGemini, - // APIModel: "gemini-2.0-flash", - // CostPer1MIn: 0.1, - // CostPer1MInCached: 0, - // CostPer1MOutCached: 0.025, - // CostPer1MOut: 0.4, - // }, - // - // // Bedrock + // Bedrock BedrockClaude37Sonnet: { ID: BedrockClaude37Sonnet, Name: "Bedrock: Claude 3.7 Sonnet", diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go index 4cfa1314e0faab0d914e49949cbac01d80f8389c..82939a6a238bd9c8670ffdbbd8ff55af3c87305f 100644 --- a/internal/llm/prompt/coder.go +++ b/internal/llm/prompt/coder.go @@ -8,9 +8,9 @@ import ( "runtime" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/llm/tools" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/llm/tools" ) func CoderPrompt(provider models.ModelProvider) string { @@ -25,7 +25,7 @@ func CoderPrompt(provider models.ModelProvider) string { } const baseOpenAICoderPrompt = ` -You are operating as and within the OpenCode CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful. +You are operating as and within the Crush CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful. You can: - Receive user prompts, project context, and files. @@ -33,7 +33,7 @@ You can: - Apply patches, run commands, and manage user approvals based on policy. - Work inside a sandboxed, git-backed workspace with rollback support. - Log telemetry so sessions can be replayed or inspected later. -- More details on your functionality are available at "opencode --help" +- More details on your functionality are available at "crush --help" You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer. @@ -71,17 +71,17 @@ You MUST adhere to the following criteria when executing the task: - Remember the user does not see the full output of tools ` -const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. +const baseAnthropicCoderPrompt = `You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. # Memory -If the current working directory contains a file called OpenCode.md, it will be automatically added to your context. This file serves multiple purposes: +If the current working directory contains a file called Crush.md, it will be automatically added to your context. This file serves multiple purposes: 1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time 2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.) 3. Maintaining useful information about the codebase structure and organization -When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to OpenCode.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to OpenCode.md so you can remember it for next time. +When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to Crush.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to Crush.md so you can remember it for next time. # Tone and style You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). @@ -153,16 +153,16 @@ When making changes to files, first understand the file's code conventions. Mimi # Doing tasks The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: -1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially. +1. Use the available search tools to understand the codebase and the user's query. 2. Implement the solution using all tools available to you 3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. -4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to opencode.md so that you will know to run it next time. +4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to crush.md so that you will know to run it next time. NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. # Tool usage policy - When doing file search, prefer to use the Agent tool in order to reduce context usage. -- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block. +- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel. - IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user. You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.` diff --git a/internal/llm/prompt/prompt.go b/internal/llm/prompt/prompt.go index 8cdbdfc269cad3be04bd471d1d39756254541c74..0e46806895d78b09cbe8c1249eadd2b755ca5d56 100644 --- a/internal/llm/prompt/prompt.go +++ b/internal/llm/prompt/prompt.go @@ -7,9 +7,9 @@ import ( "strings" "sync" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/logging" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/logging" ) func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string { diff --git a/internal/llm/prompt/prompt_test.go b/internal/llm/prompt/prompt_test.go index 405ad5194b85c208c52749cecc5ca9f84b05c614..a350c55a32260173dabd56e22d9e514e97b3e5a3 100644 --- a/internal/llm/prompt/prompt_test.go +++ b/internal/llm/prompt/prompt_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "testing" - "github.com/opencode-ai/opencode/internal/config" + "github.com/charmbracelet/crush/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,13 +44,13 @@ func createTestFiles(t *testing.T, tmpDir string, testFiles []string) { for _, path := range testFiles { fullPath := filepath.Join(tmpDir, path) if path[len(path)-1] == '/' { - err := os.MkdirAll(fullPath, 0755) + err := os.MkdirAll(fullPath, 0o755) require.NoError(t, err) } else { dir := filepath.Dir(fullPath) - err := os.MkdirAll(dir, 0755) + err := os.MkdirAll(dir, 0o755) require.NoError(t, err) - err = os.WriteFile(fullPath, []byte(path+": test content"), 0644) + err = os.WriteFile(fullPath, []byte(path+": test content"), 0o644) require.NoError(t, err) } } diff --git a/internal/llm/prompt/summarizer.go b/internal/llm/prompt/summarizer.go index cbdadecaecb56fba6c773e8db2aa0ad9963aa2fd..87a0f95c66af8b51d07a3a4e792c07dea7dab503 100644 --- a/internal/llm/prompt/summarizer.go +++ b/internal/llm/prompt/summarizer.go @@ -1,6 +1,6 @@ package prompt -import "github.com/opencode-ai/opencode/internal/llm/models" +import "github.com/charmbracelet/crush/internal/llm/models" func SummarizerPrompt(_ models.ModelProvider) string { return `You are a helpful AI assistant tasked with summarizing conversations. diff --git a/internal/llm/prompt/task.go b/internal/llm/prompt/task.go index 2e52ce5d3e85ed99c66bba779d05df5ae48719cc..53fd67dc2f88928b4fbe9773db0cd1487bcd811a 100644 --- a/internal/llm/prompt/task.go +++ b/internal/llm/prompt/task.go @@ -3,11 +3,11 @@ package prompt import ( "fmt" - "github.com/opencode-ai/opencode/internal/llm/models" + "github.com/charmbracelet/crush/internal/llm/models" ) func TaskPrompt(_ models.ModelProvider) string { - agentPrompt := `You are an agent for OpenCode. Given the user's prompt, you should use the tools available to you to answer the user's question. + agentPrompt := `You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question. Notes: 1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is .", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". 2. When relevant, share file names and code snippets relevant to the query diff --git a/internal/llm/prompt/title.go b/internal/llm/prompt/title.go index 95648152028513f8f06c73077f249193f88421ff..03e47288507fa66bb88605bff4b2194b889cc3f7 100644 --- a/internal/llm/prompt/title.go +++ b/internal/llm/prompt/title.go @@ -1,6 +1,6 @@ package prompt -import "github.com/opencode-ai/opencode/internal/llm/models" +import "github.com/charmbracelet/crush/internal/llm/models" func TitlePrompt(_ models.ModelProvider) string { return `you will generate a short title based on the first message a user begins a conversation with diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index badf6a3a07df27f6494bdbf9692f174e0a17a1ce..f5f627c228f5708307980efdcaf9e35a8a9f48c8 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -12,11 +12,11 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/bedrock" "github.com/anthropics/anthropic-sdk-go/option" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/message" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/message" ) type anthropicOptions struct { @@ -195,7 +195,7 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to } } -func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) { +func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) { preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools)) cfg := config.Get() if cfg.Debug { @@ -305,7 +305,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message ToolCall: &message.ToolCall{ ID: currentToolCallID, Finished: false, - Input: event.Delta.JSON.PartialJSON.Raw(), + Input: event.Delta.PartialJSON, }, } } @@ -339,6 +339,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message Usage: a.usage(accumulatedMessage), FinishReason: a.finishReason(string(accumulatedMessage.StopReason)), }, + Content: content, } } } diff --git a/internal/llm/provider/azure.go b/internal/llm/provider/azure.go index 6368a181c8188c8e2bf2096df1e58f3f978a5130..33a04cb3a79be4d9cf2845031f07f3ebaf473e8c 100644 --- a/internal/llm/provider/azure.go +++ b/internal/llm/provider/azure.go @@ -16,7 +16,6 @@ type azureClient struct { type AzureClient ProviderClient func newAzureClient(opts providerClientOptions) AzureClient { - endpoint := os.Getenv("AZURE_OPENAI_ENDPOINT") // ex: https://foo.openai.azure.com apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") // ex: 2025-04-01-preview diff --git a/internal/llm/provider/bedrock.go b/internal/llm/provider/bedrock.go index 9f42e5b18e291474cc86dfe1aedbaba8b7d36c00..8d3a86198aab5a38742e33b167f2545efd808873 100644 --- a/internal/llm/provider/bedrock.go +++ b/internal/llm/provider/bedrock.go @@ -7,8 +7,8 @@ import ( "os" "strings" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/message" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/message" ) type bedrockOptions struct { @@ -98,4 +98,3 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, return b.childProvider.stream(ctx, messages, tools) } - diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index ebc3611994045ab8bf1bc00c37ab5e24416998a3..57a81d9af0dbc97db992d43f246c4cde8e9927a4 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -9,11 +9,11 @@ import ( "strings" "time" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/message" "github.com/google/uuid" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/message" "google.golang.org/genai" ) @@ -365,7 +365,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t eventChan <- ProviderEvent{Type: EventContentStop} if finalResp != nil { - finishReason := message.FinishReasonEndTurn if len(finalResp.Candidates) > 0 { finishReason = g.finishReason(finalResp.Candidates[0].FinishReason) @@ -384,7 +383,6 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t } return } - } }() diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index 8a561c77bfe53e5aafabf773ff2e57c80273558a..672ef1eb6b36bf65a8db8491cefbe83e8272845a 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -8,14 +8,14 @@ import ( "io" "time" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/message" "github.com/openai/openai-go" "github.com/openai/openai-go/option" "github.com/openai/openai-go/shared" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/message" ) type openaiOptions struct { diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index 08175450a6d85953e996c08f436982a1981053b6..66e806d6f756661362628750f51f5edb8649dbaa 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -5,9 +5,9 @@ import ( "fmt" "os" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/message" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/message" ) type EventType string @@ -130,8 +130,8 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption clientOptions.openaiOptions = append(clientOptions.openaiOptions, WithOpenAIBaseURL("https://openrouter.ai/api/v1"), WithOpenAIExtraHeaders(map[string]string{ - "HTTP-Referer": "opencode.ai", - "X-Title": "OpenCode", + "HTTP-Referer": "crush.ai", + "X-Title": "Crush", }), ) return &baseProvider[OpenAIClient]{ diff --git a/internal/llm/provider/vertexai.go b/internal/llm/provider/vertexai.go index 2a13a957204e90debf6f00c920c9c3b55b74a27d..fe2de2f4588f9dbe583e4f8af85e61eea67d5648 100644 --- a/internal/llm/provider/vertexai.go +++ b/internal/llm/provider/vertexai.go @@ -4,7 +4,7 @@ import ( "context" "os" - "github.com/opencode-ai/opencode/internal/logging" + "github.com/charmbracelet/crush/internal/logging" "google.golang.org/genai" ) diff --git a/internal/llm/tools/bash.go b/internal/llm/tools/bash.go index 7231e1d2a22860b7ead26775e38ad6cb99a26f63..d2c467fe45dccebde8aa3ac130a488dfef9c5000 100644 --- a/internal/llm/tools/bash.go +++ b/internal/llm/tools/bash.go @@ -7,9 +7,9 @@ import ( "strings" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/tools/shell" - "github.com/opencode-ai/opencode/internal/permission" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/tools/shell" + "github.com/charmbracelet/crush/internal/permission" ) type BashParams struct { @@ -122,16 +122,16 @@ When the user asks you to create a new git commit, follow these steps carefully: 4. Create the commit with a message ending with: -🤖 Generated with opencode -Co-Authored-By: opencode +🤖 Generated with crush +Co-Authored-By: crush - In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example: git commit -m "$(cat <<'EOF' Commit message here. - 🤖 Generated with opencode - Co-Authored-By: opencode + 🤖 Generated with crush + Co-Authored-By: crush EOF )" @@ -193,7 +193,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF' ## Test plan [Checklist of TODOs for testing the pull request...] -🤖 Generated with opencode +🤖 Generated with crush EOF )" diff --git a/internal/llm/tools/diagnostics.go b/internal/llm/tools/diagnostics.go index b4c5941c41ad98cae9b95558d31c055016fcc018..89ad484c134a30fa4b18526e64a25e37cc0912eb 100644 --- a/internal/llm/tools/diagnostics.go +++ b/internal/llm/tools/diagnostics.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/lsp/protocol" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/lsp/protocol" ) type DiagnosticsParams struct { diff --git a/internal/llm/tools/edit.go b/internal/llm/tools/edit.go index a5f0687cb5a43aa9a78f8b51815bfabe2b12e3f2..2411187c1b5e6b93cc9f7fff4cdfa4b2014bbca8 100644 --- a/internal/llm/tools/edit.go +++ b/internal/llm/tools/edit.go @@ -9,12 +9,12 @@ import ( "strings" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/diff" - "github.com/opencode-ai/opencode/internal/history" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/permission" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/permission" ) type EditParams struct { diff --git a/internal/llm/tools/fetch.go b/internal/llm/tools/fetch.go index 863532a0b832cdf6137d9be086469602dce32a3c..780f22a43bae7c9ec1e077c2d5878d3aeb0284ec 100644 --- a/internal/llm/tools/fetch.go +++ b/internal/llm/tools/fetch.go @@ -11,8 +11,8 @@ import ( md "github.com/JohannesKaufmann/html-to-markdown" "github.com/PuerkitoBio/goquery" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/permission" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/permission" ) type FetchParams struct { @@ -152,7 +152,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error return ToolResponse{}, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "opencode/1.0") + req.Header.Set("User-Agent", "crush/1.0") resp, err := client.Do(req) if err != nil { diff --git a/internal/llm/tools/glob.go b/internal/llm/tools/glob.go index 9894d9baab1ef778865ea10c0ea04a67848ea6e8..98d908c7d18b72a7590eb8e613bbff00a6d772d6 100644 --- a/internal/llm/tools/glob.go +++ b/internal/llm/tools/glob.go @@ -10,9 +10,9 @@ import ( "sort" "strings" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/fileutil" - "github.com/opencode-ai/opencode/internal/logging" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fileutil" + "github.com/charmbracelet/crush/internal/logging" ) const ( @@ -137,7 +137,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { logging.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err)) } - return fileutil.GlobWithDoublestar(pattern, searchPath, limit) + return fileutil.GlobWithDoubleStar(pattern, searchPath, limit) } func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) { diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index f20d61ef1ed44f50235f4ba19b8ea44ba7043eb6..f13bb194483af51c56e3fc7e20bed1bcc2f35957 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -14,8 +14,8 @@ import ( "strings" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/fileutil" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fileutil" ) type GrepParams struct { diff --git a/internal/llm/tools/ls.go b/internal/llm/tools/ls.go index 0febbf8e8f28c3d64c97e755c2bdf8068131c355..81539abe8db1ce2ccde5b8261d34474ab77ea076 100644 --- a/internal/llm/tools/ls.go +++ b/internal/llm/tools/ls.go @@ -8,7 +8,8 @@ import ( "path/filepath" "strings" - "github.com/opencode-ai/opencode/internal/config" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fileutil" ) type LSParams struct { @@ -107,7 +108,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil } - files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles) + files, truncated, err := fileutil.ListDirectory(searchPath, params.Ignore, MaxLSFiles) if err != nil { return ToolResponse{}, fmt.Errorf("error listing directory: %w", err) } @@ -128,101 +129,6 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { ), nil } -func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) { - var results []string - truncated := false - - err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil // Skip files we don't have permission to access - } - - if shouldSkip(path, ignorePatterns) { - if info.IsDir() { - return filepath.SkipDir - } - return nil - } - - if path != initialPath { - if info.IsDir() { - path = path + string(filepath.Separator) - } - results = append(results, path) - } - - if len(results) >= limit { - truncated = true - return filepath.SkipAll - } - - return nil - }) - if err != nil { - return nil, truncated, err - } - - return results, truncated, nil -} - -func shouldSkip(path string, ignorePatterns []string) bool { - base := filepath.Base(path) - - if base != "." && strings.HasPrefix(base, ".") { - return true - } - - commonIgnored := []string{ - "__pycache__", - "node_modules", - "dist", - "build", - "target", - "vendor", - "bin", - "obj", - ".git", - ".idea", - ".vscode", - ".DS_Store", - "*.pyc", - "*.pyo", - "*.pyd", - "*.so", - "*.dll", - "*.exe", - } - - if strings.Contains(path, filepath.Join("__pycache__", "")) { - return true - } - - for _, ignored := range commonIgnored { - if strings.HasSuffix(ignored, "/") { - if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) { - return true - } - } else if strings.HasPrefix(ignored, "*.") { - if strings.HasSuffix(base, ignored[1:]) { - return true - } - } else { - if base == ignored { - return true - } - } - } - - for _, pattern := range ignorePatterns { - matched, err := filepath.Match(pattern, base) - if err == nil && matched { - return true - } - } - - return false -} - func createFileTree(sortedPaths []string) []*TreeNode { root := []*TreeNode{} pathMap := make(map[string]*TreeNode) diff --git a/internal/llm/tools/ls_test.go b/internal/llm/tools/ls_test.go deleted file mode 100644 index 508cb98d36aee05ed3ea2417ed10ee364298ba27..0000000000000000000000000000000000000000 --- a/internal/llm/tools/ls_test.go +++ /dev/null @@ -1,457 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLsTool_Info(t *testing.T) { - tool := NewLsTool() - info := tool.Info() - - assert.Equal(t, LSToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "path") - assert.Contains(t, info.Parameters, "ignore") - assert.Contains(t, info.Required, "path") -} - -func TestLsTool_Run(t *testing.T) { - // Create a temporary directory for testing - tempDir, err := os.MkdirTemp("", "ls_tool_test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - // Create a test directory structure - testDirs := []string{ - "dir1", - "dir2", - "dir2/subdir1", - "dir2/subdir2", - "dir3", - "dir3/.hidden_dir", - "__pycache__", - } - - testFiles := []string{ - "file1.txt", - "file2.txt", - "dir1/file3.txt", - "dir2/file4.txt", - "dir2/subdir1/file5.txt", - "dir2/subdir2/file6.txt", - "dir3/file7.txt", - "dir3/.hidden_file.txt", - "__pycache__/cache.pyc", - ".hidden_root_file.txt", - } - - // Create directories - for _, dir := range testDirs { - dirPath := filepath.Join(tempDir, dir) - err := os.MkdirAll(dirPath, 0755) - require.NoError(t, err) - } - - // Create files - for _, file := range testFiles { - filePath := filepath.Join(tempDir, file) - err := os.WriteFile(filePath, []byte("test content"), 0644) - require.NoError(t, err) - } - - t.Run("lists directory successfully", func(t *testing.T) { - tool := NewLsTool() - params := LSParams{ - Path: tempDir, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // Check that visible directories and files are included - assert.Contains(t, response.Content, "dir1") - assert.Contains(t, response.Content, "dir2") - assert.Contains(t, response.Content, "dir3") - assert.Contains(t, response.Content, "file1.txt") - assert.Contains(t, response.Content, "file2.txt") - - // Check that hidden files and directories are not included - assert.NotContains(t, response.Content, ".hidden_dir") - assert.NotContains(t, response.Content, ".hidden_file.txt") - assert.NotContains(t, response.Content, ".hidden_root_file.txt") - - // Check that __pycache__ is not included - assert.NotContains(t, response.Content, "__pycache__") - }) - - t.Run("handles non-existent path", func(t *testing.T) { - tool := NewLsTool() - params := LSParams{ - Path: filepath.Join(tempDir, "non_existent_dir"), - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "path does not exist") - }) - - t.Run("handles empty path parameter", func(t *testing.T) { - // For this test, we need to mock the config.WorkingDirectory function - // Since we can't easily do that, we'll just check that the response doesn't contain an error message - - tool := NewLsTool() - params := LSParams{ - Path: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // The response should either contain a valid directory listing or an error - // We'll just check that it's not empty - assert.NotEmpty(t, response.Content) - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewLsTool() - call := ToolCall{ - Name: LSToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "error parsing parameters") - }) - - t.Run("respects ignore patterns", func(t *testing.T) { - tool := NewLsTool() - params := LSParams{ - Path: tempDir, - Ignore: []string{"file1.txt", "dir1"}, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // The output format is a tree, so we need to check for specific patterns - // Check that file1.txt is not directly mentioned - assert.NotContains(t, response.Content, "- file1.txt") - - // Check that dir1/ is not directly mentioned - assert.NotContains(t, response.Content, "- dir1/") - }) - - t.Run("handles relative path", func(t *testing.T) { - // Save original working directory - origWd, err := os.Getwd() - require.NoError(t, err) - defer func() { - os.Chdir(origWd) - }() - - // Change to a directory above the temp directory - parentDir := filepath.Dir(tempDir) - err = os.Chdir(parentDir) - require.NoError(t, err) - - tool := NewLsTool() - params := LSParams{ - Path: filepath.Base(tempDir), - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // Should list the temp directory contents - assert.Contains(t, response.Content, "dir1") - assert.Contains(t, response.Content, "file1.txt") - }) -} - -func TestShouldSkip(t *testing.T) { - testCases := []struct { - name string - path string - ignorePatterns []string - expected bool - }{ - { - name: "hidden file", - path: "/path/to/.hidden_file", - ignorePatterns: []string{}, - expected: true, - }, - { - name: "hidden directory", - path: "/path/to/.hidden_dir", - ignorePatterns: []string{}, - expected: true, - }, - { - name: "pycache directory", - path: "/path/to/__pycache__/file.pyc", - ignorePatterns: []string{}, - expected: true, - }, - { - name: "node_modules directory", - path: "/path/to/node_modules/package", - ignorePatterns: []string{}, - expected: false, // The shouldSkip function doesn't directly check for node_modules in the path - }, - { - name: "normal file", - path: "/path/to/normal_file.txt", - ignorePatterns: []string{}, - expected: false, - }, - { - name: "normal directory", - path: "/path/to/normal_dir", - ignorePatterns: []string{}, - expected: false, - }, - { - name: "ignored by pattern", - path: "/path/to/ignore_me.txt", - ignorePatterns: []string{"ignore_*.txt"}, - expected: true, - }, - { - name: "not ignored by pattern", - path: "/path/to/keep_me.txt", - ignorePatterns: []string{"ignore_*.txt"}, - expected: false, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := shouldSkip(tc.path, tc.ignorePatterns) - assert.Equal(t, tc.expected, result) - }) - } -} - -func TestCreateFileTree(t *testing.T) { - paths := []string{ - "/path/to/file1.txt", - "/path/to/dir1/file2.txt", - "/path/to/dir1/subdir/file3.txt", - "/path/to/dir2/file4.txt", - } - - tree := createFileTree(paths) - - // Check the structure of the tree - assert.Len(t, tree, 1) // Should have one root node - - // Check the root node - rootNode := tree[0] - assert.Equal(t, "path", rootNode.Name) - assert.Equal(t, "directory", rootNode.Type) - assert.Len(t, rootNode.Children, 1) - - // Check the "to" node - toNode := rootNode.Children[0] - assert.Equal(t, "to", toNode.Name) - assert.Equal(t, "directory", toNode.Type) - assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2 - - // Find the dir1 node - var dir1Node *TreeNode - for _, child := range toNode.Children { - if child.Name == "dir1" { - dir1Node = child - break - } - } - - require.NotNil(t, dir1Node) - assert.Equal(t, "directory", dir1Node.Type) - assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir -} - -func TestPrintTree(t *testing.T) { - // Create a simple tree - tree := []*TreeNode{ - { - Name: "dir1", - Path: "dir1", - Type: "directory", - Children: []*TreeNode{ - { - Name: "file1.txt", - Path: "dir1/file1.txt", - Type: "file", - }, - { - Name: "subdir", - Path: "dir1/subdir", - Type: "directory", - Children: []*TreeNode{ - { - Name: "file2.txt", - Path: "dir1/subdir/file2.txt", - Type: "file", - }, - }, - }, - }, - }, - { - Name: "file3.txt", - Path: "file3.txt", - Type: "file", - }, - } - - result := printTree(tree, "/root") - - // Check the output format - assert.Contains(t, result, "- /root/") - assert.Contains(t, result, " - dir1/") - assert.Contains(t, result, " - file1.txt") - assert.Contains(t, result, " - subdir/") - assert.Contains(t, result, " - file2.txt") - assert.Contains(t, result, " - file3.txt") -} - -func TestListDirectory(t *testing.T) { - // Create a temporary directory for testing - tempDir, err := os.MkdirTemp("", "list_directory_test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - // Create a test directory structure - testDirs := []string{ - "dir1", - "dir1/subdir1", - ".hidden_dir", - } - - testFiles := []string{ - "file1.txt", - "file2.txt", - "dir1/file3.txt", - "dir1/subdir1/file4.txt", - ".hidden_file.txt", - } - - // Create directories - for _, dir := range testDirs { - dirPath := filepath.Join(tempDir, dir) - err := os.MkdirAll(dirPath, 0755) - require.NoError(t, err) - } - - // Create files - for _, file := range testFiles { - filePath := filepath.Join(tempDir, file) - err := os.WriteFile(filePath, []byte("test content"), 0644) - require.NoError(t, err) - } - - t.Run("lists files with no limit", func(t *testing.T) { - files, truncated, err := listDirectory(tempDir, []string{}, 1000) - require.NoError(t, err) - assert.False(t, truncated) - - // Check that visible files and directories are included - containsPath := func(paths []string, target string) bool { - targetPath := filepath.Join(tempDir, target) - for _, path := range paths { - if strings.HasPrefix(path, targetPath) { - return true - } - } - return false - } - - assert.True(t, containsPath(files, "dir1")) - assert.True(t, containsPath(files, "file1.txt")) - assert.True(t, containsPath(files, "file2.txt")) - assert.True(t, containsPath(files, "dir1/file3.txt")) - - // Check that hidden files and directories are not included - assert.False(t, containsPath(files, ".hidden_dir")) - assert.False(t, containsPath(files, ".hidden_file.txt")) - }) - - t.Run("respects limit and returns truncated flag", func(t *testing.T) { - files, truncated, err := listDirectory(tempDir, []string{}, 2) - require.NoError(t, err) - assert.True(t, truncated) - assert.Len(t, files, 2) - }) - - t.Run("respects ignore patterns", func(t *testing.T) { - files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000) - require.NoError(t, err) - assert.False(t, truncated) - - // Check that no .txt files are included - for _, file := range files { - assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file) - } - - // But directories should still be included - containsDir := false - for _, file := range files { - if strings.Contains(file, "dir1") { - containsDir = true - break - } - } - assert.True(t, containsDir) - }) -} \ No newline at end of file diff --git a/internal/llm/tools/patch.go b/internal/llm/tools/patch.go index dcd3027b548e11699bbac0a0b641b6b8c6eafa38..f66017e25cd647190421eda40c5628b24bd1b58c 100644 --- a/internal/llm/tools/patch.go +++ b/internal/llm/tools/patch.go @@ -8,12 +8,12 @@ import ( "path/filepath" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/diff" - "github.com/opencode-ai/opencode/internal/history" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/permission" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/permission" ) type PatchParams struct { diff --git a/internal/llm/tools/shell/shell.go b/internal/llm/tools/shell/shell.go index 7d3b87e4b2f3145a7e7a157a0c31e83232048ccd..fffe8fcfe73894f30790c6a21be402332af21c9c 100644 --- a/internal/llm/tools/shell/shell.go +++ b/internal/llm/tools/shell/shell.go @@ -12,7 +12,7 @@ import ( "syscall" "time" - "github.com/opencode-ai/opencode/internal/config" + "github.com/charmbracelet/crush/internal/config" ) type PersistentShell struct { @@ -61,23 +61,23 @@ func GetPersistentShell(workingDir string) *PersistentShell { func newPersistentShell(cwd string) *PersistentShell { // Get shell configuration from config cfg := config.Get() - + // Default to environment variable if config is not set or nil var shellPath string var shellArgs []string - + if cfg != nil { shellPath = cfg.Shell.Path shellArgs = cfg.Shell.Args } - + if shellPath == "" { shellPath = os.Getenv("SHELL") if shellPath == "" { shellPath = "/bin/bash" } } - + // Default shell args if len(shellArgs) == 0 { shellArgs = []string{"-l"} @@ -149,10 +149,10 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx } tempDir := os.TempDir() - stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano())) - stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano())) - statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano())) - cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano())) + stdoutFile := filepath.Join(tempDir, fmt.Sprintf("crush-stdout-%d", time.Now().UnixNano())) + stderrFile := filepath.Join(tempDir, fmt.Sprintf("crush-stderr-%d", time.Now().UnixNano())) + statusFile := filepath.Join(tempDir, fmt.Sprintf("crush-status-%d", time.Now().UnixNano())) + cwdFile := filepath.Join(tempDir, fmt.Sprintf("crush-cwd-%d", time.Now().UnixNano())) defer func() { os.Remove(stdoutFile) diff --git a/internal/llm/tools/sourcegraph.go b/internal/llm/tools/sourcegraph.go index 0d38c975fbe202a8cd16f580586795bf2213fabb..f62e6a961bed962088e0e40670a4276f16174187 100644 --- a/internal/llm/tools/sourcegraph.go +++ b/internal/llm/tools/sourcegraph.go @@ -218,7 +218,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, } req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "opencode/1.0") + req.Header.Set("User-Agent", "crush/1.0") resp, err := client.Do(req) if err != nil { diff --git a/internal/llm/tools/view.go b/internal/llm/tools/view.go index 6d800ce6ee27902a5c99767b9954e91f2c650428..0c4652933c9b0a3e8be1b7b97a257433435993af 100644 --- a/internal/llm/tools/view.go +++ b/internal/llm/tools/view.go @@ -10,8 +10,8 @@ import ( "path/filepath" "strings" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/lsp" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/lsp" ) type ViewParams struct { diff --git a/internal/llm/tools/write.go b/internal/llm/tools/write.go index decc51e472bf1216698ab77f5d408aab816eb028..9dadc068e5517b4eb07a8c434e4d024d6e5cb78b 100644 --- a/internal/llm/tools/write.go +++ b/internal/llm/tools/write.go @@ -9,12 +9,12 @@ import ( "strings" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/diff" - "github.com/opencode-ai/opencode/internal/history" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/permission" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/permission" ) type WriteParams struct { diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 7ae2e7b87ab7f3f71811c793118c79e2a72a3bbf..9c2cfb50f33d27d52b9acb3009859f3509484253 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -54,7 +54,7 @@ func RecoverPanic(name string, cleanup func()) { // Create a timestamped panic log file timestamp := time.Now().Format("20060102-150405") - filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp) + filename := fmt.Sprintf("crush-panic-%s-%s.log", name, timestamp) file, err := os.Create(filename) if err != nil { diff --git a/internal/logging/writer.go b/internal/logging/writer.go index 50f3367db015af253869262ce139d4d36c962254..8775f3752d52f3141e1cf51a11a734c3c6e523b1 100644 --- a/internal/logging/writer.go +++ b/internal/logging/writer.go @@ -8,8 +8,8 @@ import ( "sync" "time" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/go-logfmt/logfmt" - "github.com/opencode-ai/opencode/internal/pubsub" ) const ( diff --git a/internal/lsp/client.go b/internal/lsp/client.go index d115b2404b798a7e69f378799eb9b01725ebed7c..73310fda54c94f817467b9f2eb5439d184ca794d 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -14,9 +14,9 @@ import ( "sync/atomic" "time" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/lsp/protocol" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/lsp/protocol" ) type Client struct { diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index e24945b423f5339c0e10319e466f8b0b098fd8d4..9eb258d761ee36a909cddec16b72b2a3d933a5b4 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -3,10 +3,10 @@ package lsp import ( "encoding/json" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/lsp/protocol" - "github.com/opencode-ai/opencode/internal/lsp/util" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/lsp/protocol" + "github.com/charmbracelet/crush/internal/lsp/util" ) // Requests diff --git a/internal/lsp/language.go b/internal/lsp/language.go index 89bb8f859ee81471dcf3a2de4bf0157257026418..87d209f1dbc51eafbde4d85b0ce6001dd17729b5 100644 --- a/internal/lsp/language.go +++ b/internal/lsp/language.go @@ -4,7 +4,7 @@ import ( "path/filepath" "strings" - "github.com/opencode-ai/opencode/internal/lsp/protocol" + "github.com/charmbracelet/crush/internal/lsp/protocol" ) func DetectLanguageID(uri string) protocol.LanguageKind { diff --git a/internal/lsp/methods.go b/internal/lsp/methods.go index d4f6d1c6c1aa7e782c7952b354a79389c07c4e8c..afd087c1b86d5242e845e419c47234de11ce467f 100644 --- a/internal/lsp/methods.go +++ b/internal/lsp/methods.go @@ -4,7 +4,7 @@ package lsp import ( "context" - "github.com/opencode-ai/opencode/internal/lsp/protocol" + "github.com/charmbracelet/crush/internal/lsp/protocol" ) // Implementation sends a textDocument/implementation request to the LSP server. diff --git a/internal/lsp/transport.go b/internal/lsp/transport.go index 9b07d53c9617537baa7b10a880af0537cdc8d7e1..c3d5d762feeccaaa363a189fd8014b705a583681 100644 --- a/internal/lsp/transport.go +++ b/internal/lsp/transport.go @@ -8,8 +8,8 @@ import ( "io" "strings" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/logging" ) // Write writes an LSP message to the given writer diff --git a/internal/lsp/util/edit.go b/internal/lsp/util/edit.go index 5440e2f6ceb046d9f79481cb38ea8e2c2843f55e..a67fab0a6a14e788f99a453a8488c5210f4d57d1 100644 --- a/internal/lsp/util/edit.go +++ b/internal/lsp/util/edit.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "github.com/opencode-ai/opencode/internal/lsp/protocol" + "github.com/charmbracelet/crush/internal/lsp/protocol" ) func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error { diff --git a/internal/lsp/watcher/watcher.go b/internal/lsp/watcher/watcher.go index fd7e04837185edafe2372c0f926ec55f8ec95001..3b8c36d963b88c1c4b60ef23a5c7cd9c26af4025 100644 --- a/internal/lsp/watcher/watcher.go +++ b/internal/lsp/watcher/watcher.go @@ -10,11 +10,11 @@ import ( "time" "github.com/bmatcuk/doublestar/v4" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/lsp/protocol" "github.com/fsnotify/fsnotify" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/lsp/protocol" ) // WorkspaceWatcher manages LSP file watching @@ -401,7 +401,6 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str "watched", matched, "kind", kind, ) - } // Check if this path should be watched according to server registrations diff --git a/internal/message/content.go b/internal/message/content.go index a4f636e582033173b17285122c2f00ae6e488190..383134b596e62a5fc18b2c8404d770fc6a2d4112 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -5,7 +5,7 @@ import ( "slices" "time" - "github.com/opencode-ai/opencode/internal/llm/models" + "github.com/charmbracelet/crush/internal/llm/models" ) type MessageRole string diff --git a/internal/message/message.go b/internal/message/message.go index 9c58ef202fa248804558dd0ecd027782a772edb6..9e241a0b011ee6277402709fdd8be3aefb5df6fe 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -7,10 +7,10 @@ import ( "fmt" "time" + "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/google/uuid" - "github.com/opencode-ai/opencode/internal/db" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/pubsub" ) type CreateMessageParams struct { @@ -274,7 +274,6 @@ func unmarshallParts(data []byte) ([]ContentPart, error) { default: return nil, fmt.Errorf("unknown part type: %s", wrapper.Type) } - } return parts, nil diff --git a/internal/permission/permission.go b/internal/permission/permission.go index d6fdea664465bb3c5318e320e68f992a27e58a13..6790e1d208c02f24a9640b464f0253ef69cfcc77 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -6,9 +6,9 @@ import ( "slices" "sync" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/google/uuid" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/pubsub" ) var ErrorPermissionDenied = errors.New("permission denied") @@ -34,7 +34,7 @@ type PermissionRequest struct { type Service interface { pubsub.Suscriber[PermissionRequest] - GrantPersistant(permission PermissionRequest) + GrantPersistent(permission PermissionRequest) Grant(permission PermissionRequest) Deny(permission PermissionRequest) Request(opts CreatePermissionRequest) bool @@ -49,7 +49,7 @@ type permissionService struct { autoApproveSessions []string } -func (s *permissionService) GrantPersistant(permission PermissionRequest) { +func (s *permissionService) GrantPersistent(permission PermissionRequest) { respCh, ok := s.pendingRequests.Load(permission.ID) if ok { respCh.(chan bool) <- true diff --git a/internal/session/session.go b/internal/session/session.go index c6e7f60bfbfe52e54071183b0cc9f399363904d6..d988dac3414fa7dd00d13b375e1309f8d6c515dd 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -4,9 +4,9 @@ import ( "context" "database/sql" + "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/google/uuid" - "github.com/opencode-ai/opencode/internal/db" - "github.com/opencode-ai/opencode/internal/pubsub" ) type Session struct { diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go new file mode 100644 index 0000000000000000000000000000000000000000..0bd7a7753f114c7bfb6c8f9898772c49ae8f7d80 --- /dev/null +++ b/internal/tui/components/anim/anim.go @@ -0,0 +1,307 @@ +package anim + +import ( + "fmt" + "image/color" + "math/rand/v2" + "strings" + "time" + + "github.com/charmbracelet/bubbles/v2/spinner" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" + "github.com/google/uuid" + "github.com/lucasb-eyer/go-colorful" +) + +const ( + charCyclingFPS = time.Second / 22 + colorCycleFPS = time.Second / 5 + maxCyclingChars = 120 +) + +var charRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_") + +type charState int + +const ( + charInitialState charState = iota + charCyclingState + charEndOfLifeState +) + +// cyclingChar is a single animated character. +type cyclingChar struct { + finalValue rune // if < 0 cycle forever + currentValue rune + initialDelay time.Duration + lifetime time.Duration +} + +func (c cyclingChar) randomRune() rune { + return (charRunes)[rand.IntN(len(charRunes))] //nolint:gosec +} + +func (c cyclingChar) state(start time.Time) charState { + now := time.Now() + if now.Before(start.Add(c.initialDelay)) { + return charInitialState + } + if c.finalValue > 0 && now.After(start.Add(c.initialDelay)) { + return charEndOfLifeState + } + return charCyclingState +} + +type StepCharsMsg struct { + id string +} + +func stepChars(id string) tea.Cmd { + return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg { + return StepCharsMsg{id} + }) +} + +type ColorCycleMsg struct { + id string +} + +func cycleColors(id string) tea.Cmd { + return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg { + return ColorCycleMsg{id} + }) +} + +type Animation interface { + util.Model + ID() string +} + +// anim is the model that manages the animation that displays while the +// output is being generated. +type anim struct { + start time.Time + cyclingChars []cyclingChar + labelChars []cyclingChar + ramp []lipgloss.Style + label []rune + ellipsis spinner.Model + ellipsisStarted bool + id string +} + +type animOption func(*anim) + +func WithId(id string) animOption { + return func(a *anim) { + a.id = id + } +} + +func New(cyclingCharsSize uint, label string, opts ...animOption) Animation { + // #nosec G115 + n := min(int(cyclingCharsSize), maxCyclingChars) + + gap := " " + if n == 0 { + gap = "" + } + + id := uuid.New() + c := anim{ + start: time.Now(), + label: []rune(gap + label), + ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)), + id: id.String(), + } + + for _, opt := range opts { + opt(&c) + } + + // If we're in truecolor mode (and there are enough cycling characters) + // color the cycling characters with a gradient ramp. + const minRampSize = 3 + if n >= minRampSize { + // Note: double capacity for color cycling as we'll need to reverse and + // append the ramp for seamless transitions. + c.ramp = make([]lipgloss.Style, n, n*2) //nolint:mnd + ramp := makeGradientRamp(n) + for i, color := range ramp { + c.ramp[i] = lipgloss.NewStyle().Foreground(color) + } + c.ramp = append(c.ramp, reverse(c.ramp)...) // reverse and append for color cycling + } + + makeDelay := func(a int32, b time.Duration) time.Duration { + return time.Duration(rand.Int32N(a)) * (time.Millisecond * b) //nolint:gosec + } + + makeInitialDelay := func() time.Duration { + return makeDelay(8, 60) //nolint:mnd + } + + // Initial characters that cycle forever. + c.cyclingChars = make([]cyclingChar, n) + + for i := range n { + c.cyclingChars[i] = cyclingChar{ + finalValue: -1, // cycle forever + initialDelay: makeInitialDelay(), + } + } + + // Label text that only cycles for a little while. + c.labelChars = make([]cyclingChar, len(c.label)) + + for i, r := range c.label { + c.labelChars[i] = cyclingChar{ + finalValue: r, + initialDelay: makeInitialDelay(), + lifetime: makeDelay(5, 180), //nolint:mnd + } + } + + return c +} + +// Init initializes the animation. +func (a anim) Init() tea.Cmd { + return tea.Batch(stepChars(a.id), cycleColors(a.id)) +} + +// Update handles messages. +func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case StepCharsMsg: + if msg.id != a.id { + return a, nil + } + a.updateChars(&a.cyclingChars) + a.updateChars(&a.labelChars) + + if !a.ellipsisStarted { + var eol int + for _, c := range a.labelChars { + if c.state(a.start) == charEndOfLifeState { + eol++ + } + } + if eol == len(a.label) { + // If our entire label has reached end of life, start the + // ellipsis "spinner" after a short pause. + a.ellipsisStarted = true + cmd = tea.Tick(time.Millisecond*220, func(time.Time) tea.Msg { //nolint:mnd + return a.ellipsis.Tick() + }) + } + } + + return a, tea.Batch(stepChars(a.id), cmd) + case ColorCycleMsg: + if msg.id != a.id { + return a, nil + } + const minColorCycleSize = 2 + if len(a.ramp) < minColorCycleSize { + return a, nil + } + a.ramp = append(a.ramp[1:], a.ramp[0]) + return a, cycleColors(a.id) + case spinner.TickMsg: + var cmd tea.Cmd + a.ellipsis, cmd = a.ellipsis.Update(msg) + return a, cmd + default: + return a, nil + } +} + +func (a anim) ID() string { + return a.id +} + +func (a *anim) updateChars(chars *[]cyclingChar) { + charSlice := *chars // dereference to avoid repeated pointer access + for i, c := range charSlice { + switch c.state(a.start) { + case charInitialState: + charSlice[i].currentValue = '.' + case charCyclingState: + charSlice[i].currentValue = c.randomRune() + case charEndOfLifeState: + charSlice[i].currentValue = c.finalValue + } + } +} + +// View renders the animation. +func (a anim) View() tea.View { + var ( + t = styles.CurrentTheme() + b strings.Builder + ) + + // Pre-allocate builder capacity to avoid reallocations. + // Estimate: cycling chars + label chars + ellipsis + style overhead. + const ( + bytesPerChar = 20 // ANSI styling + bufferSize = 50 // ellipsis and safety margin + ) + estimatedCap := len(a.cyclingChars)*bytesPerChar + len(a.labelChars)*bytesPerChar + bufferSize + b.Grow(estimatedCap) + + for i, c := range a.cyclingChars { + if len(a.ramp) > i { + b.WriteString(a.ramp[i].Render(string(c.currentValue))) + continue + } + b.WriteRune(c.currentValue) + } + + if len(a.labelChars) > 1 { + textStyle := t.S().Text + for _, c := range a.labelChars { + b.WriteString( + textStyle.Render(string(c.currentValue)), + ) + } + b.WriteString(textStyle.Render(a.ellipsis.View())) + } + + return tea.NewView(b.String()) +} + +func GetColor(c color.Color) string { + rgba := color.RGBAModel.Convert(c).(color.RGBA) + return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) +} + +func makeGradientRamp(length int) []color.Color { + t := styles.CurrentTheme() + startColor := GetColor(t.Primary) + endColor := GetColor(t.Secondary) + var ( + c = make([]color.Color, length) + start, _ = colorful.Hex(startColor) + end, _ = colorful.Hex(endColor) + ) + for i := range length { + step := start.BlendLuv(end, float64(i)/float64(length)) + c[i] = lipgloss.Color(step.Hex()) + } + return c +} + +func reverse[T any](in []T) []T { + out := make([]T, len(in)) + copy(out, in[:]) + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 1ad3f683d7a7e7f851c6e6696d4d7d4692f313bd..778d36b10d11a804be7ca0c65b23e632d745f3ab 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -1,17 +1,20 @@ package chat import ( - "fmt" - "sort" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/version" + "context" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/components/chat/messages" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" ) type SendMsg struct { @@ -23,119 +26,472 @@ type SessionSelectedMsg = session.Session type SessionClearedMsg struct{} -type EditorFocusMsg bool +const ( + NotFound = -1 +) + +// MessageListCmp represents a component that displays a list of chat messages +// with support for real-time updates and session management. +type MessageListCmp interface { + util.Model + layout.Sizeable + layout.Focusable +} + +// messageListCmp implements MessageListCmp, providing a virtualized list +// of chat messages with support for tool calls, real-time updates, and +// session switching. +type messageListCmp struct { + app *app.App + width, height int + session session.Session + listCmp list.ListModel + previousSelected int // Last selected item index for restoring focus + + lastUserMessageTime int64 +} + +// NewMessagesListCmp creates a new message list component with custom keybindings +// and reverse ordering (newest messages at bottom). +func NewMessagesListCmp(app *app.App) MessageListCmp { + defaultKeymaps := list.DefaultKeyMap() + listCmp := list.New( + list.WithGapSize(1), + list.WithReverse(true), + list.WithKeyMap(defaultKeymaps), + ) + return &messageListCmp{ + app: app, + listCmp: listCmp, + previousSelected: list.NoSelection, + } +} + +// Init initializes the component (no initialization needed). +func (m *messageListCmp) Init() tea.Cmd { + return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur()) +} + +// Update handles incoming messages and updates the component state. +func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case SessionSelectedMsg: + if msg.ID != m.session.ID { + cmd := m.SetSession(msg) + return m, cmd + } + return m, nil + case SessionClearedMsg: + m.session = session.Session{} + return m, m.listCmp.SetItems([]util.Model{}) -func header(width int) string { - return lipgloss.JoinVertical( - lipgloss.Top, - logo(width), - repo(width), - "", - cwd(width), + case pubsub.Event[message.Message]: + cmd := m.handleMessageEvent(msg) + return m, cmd + default: + var cmds []tea.Cmd + u, cmd := m.listCmp.Update(msg) + m.listCmp = u.(list.ListModel) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } +} + +// View renders the message list or an initial screen if empty. +func (m *messageListCmp) View() tea.View { + return tea.NewView( + lipgloss.JoinVertical( + lipgloss.Left, + m.listCmp.View().String(), + ), + ) +} + +// handleChildSession handles messages from child sessions (agent tools). +func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd { + var cmds []tea.Cmd + if len(event.Payload.ToolCalls()) == 0 { + return nil + } + items := m.listCmp.Items() + toolCallInx := NotFound + var toolCall messages.ToolCallCmp + for i := len(items) - 1; i >= 0; i-- { + if msg, ok := items[i].(messages.ToolCallCmp); ok { + if msg.GetToolCall().ID == event.Payload.SessionID { + toolCallInx = i + toolCall = msg + } + } + } + if toolCallInx == NotFound { + return nil + } + nestedToolCalls := toolCall.GetNestedToolCalls() + for _, tc := range event.Payload.ToolCalls() { + found := false + for existingInx, existingTC := range nestedToolCalls { + if existingTC.GetToolCall().ID == tc.ID { + nestedToolCalls[existingInx].SetToolCall(tc) + found = true + break + } + } + if !found { + nestedCall := messages.NewToolCallCmp( + event.Payload.ID, + tc, + messages.WithToolCallNested(true), + ) + cmds = append(cmds, nestedCall.Init()) + nestedToolCalls = append( + nestedToolCalls, + nestedCall, + ) + } + } + toolCall.SetNestedToolCalls(nestedToolCalls) + m.listCmp.UpdateItem( + toolCallInx, + toolCall, ) + return tea.Batch(cmds...) } -func lspsConfigured(width int) string { - cfg := config.Get() - title := "LSP Configuration" - title = ansi.Truncate(title, width, "…") - - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - lsps := baseStyle. - Width(width). - Foreground(t.Primary()). - Bold(true). - Render(title) - - // Get LSP names and sort them for consistent ordering - var lspNames []string - for name := range cfg.LSP { - lspNames = append(lspNames, name) - } - sort.Strings(lspNames) - - var lspViews []string - for _, name := range lspNames { - lsp := cfg.LSP[name] - lspName := baseStyle. - Foreground(t.Text()). - Render(fmt.Sprintf("• %s", name)) - - cmd := lsp.Command - cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…") - - lspPath := baseStyle. - Foreground(t.TextMuted()). - Render(fmt.Sprintf(" (%s)", cmd)) - - lspViews = append(lspViews, - baseStyle. - Width(width). - Render( - lipgloss.JoinHorizontal( - lipgloss.Left, - lspName, - lspPath, - ), - ), +// handleMessageEvent processes different types of message events (created/updated). +func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd { + switch event.Type { + case pubsub.CreatedEvent: + if event.Payload.SessionID != m.session.ID { + return m.handleChildSession(event) + } + if m.messageExists(event.Payload.ID) { + return nil + } + return m.handleNewMessage(event.Payload) + case pubsub.UpdatedEvent: + if event.Payload.SessionID != m.session.ID { + return m.handleChildSession(event) + } + return m.handleUpdateAssistantMessage(event.Payload) + } + return nil +} + +// messageExists checks if a message with the given ID already exists in the list. +func (m *messageListCmp) messageExists(messageID string) bool { + items := m.listCmp.Items() + // Search backwards as new messages are more likely to be at the end + for i := len(items) - 1; i >= 0; i-- { + if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID { + return true + } + } + return false +} + +// handleNewMessage routes new messages to appropriate handlers based on role. +func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd { + switch msg.Role { + case message.User: + return m.handleNewUserMessage(msg) + case message.Assistant: + return m.handleNewAssistantMessage(msg) + case message.Tool: + return m.handleToolMessage(msg) + } + return nil +} + +// handleNewUserMessage adds a new user message to the list and updates the timestamp. +func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd { + m.lastUserMessageTime = msg.CreatedAt + return m.listCmp.AppendItem(messages.NewMessageCmp(msg)) +} + +// handleToolMessage updates existing tool calls with their results. +func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd { + items := m.listCmp.Items() + for _, tr := range msg.ToolResults() { + if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound { + toolCall := items[toolCallIndex].(messages.ToolCallCmp) + toolCall.SetToolResult(tr) + m.listCmp.UpdateItem(toolCallIndex, toolCall) + } + } + return nil +} + +// findToolCallByID searches for a tool call with the specified ID. +// Returns the index if found, NotFound otherwise. +func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int { + // Search backwards as tool calls are more likely to be recent + for i := len(items) - 1; i >= 0; i-- { + if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID { + return i + } + } + return NotFound +} + +// handleUpdateAssistantMessage processes updates to assistant messages, +// managing both message content and associated tool calls. +func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd { + var cmds []tea.Cmd + items := m.listCmp.Items() + + // Find existing assistant message and tool calls for this message + assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID) + + // Handle assistant message content + if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil { + cmds = append(cmds, cmd) + } + + // Handle tool calls + if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil { + cmds = append(cmds, cmd) + } + + return tea.Batch(cmds...) +} + +// findAssistantMessageAndToolCalls locates the assistant message and its tool calls. +func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) { + assistantIndex := NotFound + toolCalls := make(map[int]messages.ToolCallCmp) + + // Search backwards as messages are more likely to be at the end + for i := len(items) - 1; i >= 0; i-- { + item := items[i] + if asMsg, ok := item.(messages.MessageCmp); ok { + if asMsg.GetMessage().ID == messageID { + assistantIndex = i + } + } else if tc, ok := item.(messages.ToolCallCmp); ok { + if tc.ParentMessageId() == messageID { + toolCalls[i] = tc + } + } + } + + return assistantIndex, toolCalls +} + +// updateAssistantMessageContent updates or removes the assistant message based on content. +func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd { + if assistantIndex == NotFound { + return nil + } + + shouldShowMessage := m.shouldShowAssistantMessage(msg) + hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == "" + + if shouldShowMessage { + m.listCmp.UpdateItem( + assistantIndex, + messages.NewMessageCmp( + msg, + messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)), + ), ) + } else if hasToolCallsOnly { + m.listCmp.DeleteItem(assistantIndex) + } + + return nil +} + +// shouldShowAssistantMessage determines if an assistant message should be displayed. +func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool { + return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() +} + +// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones. +func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd { + var cmds []tea.Cmd + + for _, tc := range msg.ToolCalls() { + if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil { + cmds = append(cmds, cmd) + } + } + + return tea.Batch(cmds...) +} + +// updateOrAddToolCall updates an existing tool call or adds a new one. +func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd { + // Try to find existing tool call + for index, existingTC := range existingToolCalls { + if tc.ID == existingTC.GetToolCall().ID { + existingTC.SetToolCall(tc) + m.listCmp.UpdateItem(index, existingTC) + return nil + } } - return baseStyle. - Width(width). - Render( - lipgloss.JoinVertical( - lipgloss.Left, - lsps, - lipgloss.JoinVertical( - lipgloss.Left, - lspViews..., - ), + // Add new tool call if not found + return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc)) +} + +// handleNewAssistantMessage processes new assistant messages and their tool calls. +func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd { + var cmds []tea.Cmd + + // Add assistant message if it should be displayed + if m.shouldShowAssistantMessage(msg) { + cmd := m.listCmp.AppendItem( + messages.NewMessageCmp( + msg, + messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)), ), ) + cmds = append(cmds, cmd) + } + + // Add tool calls + for _, tc := range msg.ToolCalls() { + cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc)) + cmds = append(cmds, cmd) + } + + return tea.Batch(cmds...) } -func logo(width int) string { - logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode") - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - versionText := baseStyle. - Foreground(t.TextMuted()). - Render(version.Version) - - return baseStyle. - Bold(true). - Width(width). - Render( - lipgloss.JoinHorizontal( - lipgloss.Left, - logo, - " ", - versionText, +// SetSession loads and displays messages for a new session. +func (m *messageListCmp) SetSession(session session.Session) tea.Cmd { + if m.session.ID == session.ID { + return nil + } + + m.session = session + sessionMessages, err := m.app.Messages.List(context.Background(), session.ID) + if err != nil { + return util.ReportError(err) + } + + if len(sessionMessages) == 0 { + return m.listCmp.SetItems([]util.Model{}) + } + + // Initialize with first message timestamp + m.lastUserMessageTime = sessionMessages[0].CreatedAt + + // Build tool result map for efficient lookup + toolResultMap := m.buildToolResultMap(sessionMessages) + + // Convert messages to UI components + uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap) + + return m.listCmp.SetItems(uiMessages) +} + +// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup. +func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult { + toolResultMap := make(map[string]message.ToolResult) + for _, msg := range messages { + for _, tr := range msg.ToolResults() { + toolResultMap[tr.ToolCallID] = tr + } + } + return toolResultMap +} + +// convertMessagesToUI converts database messages to UI components. +func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model { + uiMessages := make([]util.Model, 0) + + for _, msg := range sessionMessages { + switch msg.Role { + case message.User: + m.lastUserMessageTime = msg.CreatedAt + uiMessages = append(uiMessages, messages.NewMessageCmp(msg)) + case message.Assistant: + uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...) + } + } + + return uiMessages +} + +// convertAssistantMessage converts an assistant message and its tool calls to UI components. +func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model { + var uiMessages []util.Model + + // Add assistant message if it should be displayed + if m.shouldShowAssistantMessage(msg) { + uiMessages = append( + uiMessages, + messages.NewMessageCmp( + msg, + messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)), ), ) + } + + // Add tool calls with their results and status + for _, tc := range msg.ToolCalls() { + options := m.buildToolCallOptions(tc, msg, toolResultMap) + uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...)) + // If this tool call is the agent tool, fetch nested tool calls + if tc.Name == agent.AgentToolName { + nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID) + nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult)) + nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages)) + for _, nestedMsg := range nestedUIMessages { + if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok { + toolCall.SetIsNested(true) + nestedToolCalls = append(nestedToolCalls, toolCall) + } + } + uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls) + } + } + + return uiMessages } -func repo(width int) string { - repo := "https://github.com/opencode-ai/opencode" - t := theme.CurrentTheme() +// buildToolCallOptions creates options for tool call components based on results and status. +func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption { + var options []messages.ToolCallOption + + // Add tool result if available + if tr, ok := toolResultMap[tc.ID]; ok { + options = append(options, messages.WithToolCallResult(tr)) + } + + // Add cancelled status if applicable + if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled { + options = append(options, messages.WithToolCallCancelled()) + } - return styles.BaseStyle(). - Foreground(t.TextMuted()). - Width(width). - Render(repo) + return options } -func cwd(width int) string { - cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory()) - t := theme.CurrentTheme() +// GetSize returns the current width and height of the component. +func (m *messageListCmp) GetSize() (int, int) { + return m.width, m.height +} + +// SetSize updates the component dimensions and propagates to the list component. +func (m *messageListCmp) SetSize(width int, height int) tea.Cmd { + m.width = width + m.height = height - 1 + return m.listCmp.SetSize(width, height-1) +} - return styles.BaseStyle(). - Foreground(t.TextMuted()). - Width(width). - Render(cwd) +// Blur implements MessageListCmp. +func (m *messageListCmp) Blur() tea.Cmd { + return m.listCmp.Blur() } +// Focus implements MessageListCmp. +func (m *messageListCmp) Focus() tea.Cmd { + return m.listCmp.Focus() +} + +// IsFocused implements MessageListCmp. +func (m *messageListCmp) IsFocused() bool { + return m.listCmp.IsFocused() +} diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go deleted file mode 100644 index a6c5a44e8d96bdd6cc69a52f3f0ac95fad883fb3..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/editor.go +++ /dev/null @@ -1,318 +0,0 @@ -package chat - -import ( - "fmt" - "os" - "os/exec" - "slices" - "strings" - "unicode" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textarea" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/components/dialog" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -type editorCmp struct { - width int - height int - app *app.App - session session.Session - textarea textarea.Model - attachments []message.Attachment - deleteMode bool -} - -type EditorKeyMaps struct { - Send key.Binding - OpenEditor key.Binding -} - -type bluredEditorKeyMaps struct { - Send key.Binding - Focus key.Binding - OpenEditor key.Binding -} -type DeleteAttachmentKeyMaps struct { - AttachmentDeleteMode key.Binding - Escape key.Binding - DeleteAllAttachments key.Binding -} - -var editorMaps = EditorKeyMaps{ - Send: key.NewBinding( - key.WithKeys("enter", "ctrl+s"), - key.WithHelp("enter", "send message"), - ), - OpenEditor: key.NewBinding( - key.WithKeys("ctrl+e"), - key.WithHelp("ctrl+e", "open editor"), - ), -} - -var DeleteKeyMaps = DeleteAttachmentKeyMaps{ - AttachmentDeleteMode: key.NewBinding( - key.WithKeys("ctrl+r"), - key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel delete mode"), - ), - DeleteAllAttachments: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("ctrl+r+r", "delete all attchments"), - ), -} - -const ( - maxAttachments = 5 -) - -func (m *editorCmp) openEditor() tea.Cmd { - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "nvim" - } - - tmpfile, err := os.CreateTemp("", "msg_*.md") - if err != nil { - return util.ReportError(err) - } - tmpfile.Close() - c := exec.Command(editor, tmpfile.Name()) //nolint:gosec - c.Stdin = os.Stdin - c.Stdout = os.Stdout - c.Stderr = os.Stderr - return tea.ExecProcess(c, func(err error) tea.Msg { - if err != nil { - return util.ReportError(err) - } - content, err := os.ReadFile(tmpfile.Name()) - if err != nil { - return util.ReportError(err) - } - if len(content) == 0 { - return util.ReportWarn("Message is empty") - } - os.Remove(tmpfile.Name()) - attachments := m.attachments - m.attachments = nil - return SendMsg{ - Text: string(content), - Attachments: attachments, - } - }) -} - -func (m *editorCmp) Init() tea.Cmd { - return textarea.Blink -} - -func (m *editorCmp) send() tea.Cmd { - if m.app.CoderAgent.IsSessionBusy(m.session.ID) { - return util.ReportWarn("Agent is working, please wait...") - } - - value := m.textarea.Value() - m.textarea.Reset() - attachments := m.attachments - - m.attachments = nil - if value == "" { - return nil - } - return tea.Batch( - util.CmdHandler(SendMsg{ - Text: value, - Attachments: attachments, - }), - ) -} - -func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case dialog.ThemeChangedMsg: - m.textarea = CreateTextArea(&m.textarea) - case dialog.CompletionSelectedMsg: - existingValue := m.textarea.Value() - modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1) - - m.textarea.SetValue(modifiedValue) - return m, nil - case SessionSelectedMsg: - if msg.ID != m.session.ID { - m.session = msg - } - return m, nil - case dialog.AttachmentAddedMsg: - if len(m.attachments) >= maxAttachments { - logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments)) - return m, cmd - } - m.attachments = append(m.attachments, msg.Attachment) - case tea.KeyMsg: - if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) { - m.deleteMode = true - return m, nil - } - if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode { - m.deleteMode = false - m.attachments = nil - return m, nil - } - if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) { - num := int(msg.Runes[0] - '0') - m.deleteMode = false - if num < 10 && len(m.attachments) > num { - if num == 0 { - m.attachments = m.attachments[num+1:] - } else { - m.attachments = slices.Delete(m.attachments, num, num+1) - } - return m, nil - } - } - if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) || - key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) { - return m, nil - } - if key.Matches(msg, editorMaps.OpenEditor) { - if m.app.CoderAgent.IsSessionBusy(m.session.ID) { - return m, util.ReportWarn("Agent is working, please wait...") - } - return m, m.openEditor() - } - if key.Matches(msg, DeleteKeyMaps.Escape) { - m.deleteMode = false - return m, nil - } - // Hanlde Enter key - if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) { - value := m.textarea.Value() - if len(value) > 0 && value[len(value)-1] == '\\' { - // If the last character is a backslash, remove it and add a newline - m.textarea.SetValue(value[:len(value)-1] + "\n") - return m, nil - } else { - // Otherwise, send the message - return m, m.send() - } - } - - } - m.textarea, cmd = m.textarea.Update(msg) - return m, cmd -} - -func (m *editorCmp) View() string { - t := theme.CurrentTheme() - - // Style the prompt with theme colors - style := lipgloss.NewStyle(). - Padding(0, 0, 0, 1). - Bold(true). - Foreground(t.Primary()) - - if len(m.attachments) == 0 { - return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()) - } - m.textarea.SetHeight(m.height - 1) - return lipgloss.JoinVertical(lipgloss.Top, - m.attachmentsContent(), - lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), - m.textarea.View()), - ) -} - -func (m *editorCmp) SetSize(width, height int) tea.Cmd { - m.width = width - m.height = height - m.textarea.SetWidth(width - 3) // account for the prompt and padding right - m.textarea.SetHeight(height) - m.textarea.SetWidth(width) - return nil -} - -func (m *editorCmp) GetSize() (int, int) { - return m.textarea.Width(), m.textarea.Height() -} - -func (m *editorCmp) attachmentsContent() string { - var styledAttachments []string - t := theme.CurrentTheme() - attachmentStyles := styles.BaseStyle(). - MarginLeft(1). - Background(t.TextMuted()). - Foreground(t.Text()) - for i, attachment := range m.attachments { - var filename string - if len(attachment.FileName) > 10 { - filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7]) - } else { - filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName) - } - if m.deleteMode { - filename = fmt.Sprintf("%d%s", i, filename) - } - styledAttachments = append(styledAttachments, attachmentStyles.Render(filename)) - } - content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...) - return content -} - -func (m *editorCmp) BindingKeys() []key.Binding { - bindings := []key.Binding{} - bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...) - bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...) - return bindings -} - -func CreateTextArea(existing *textarea.Model) textarea.Model { - t := theme.CurrentTheme() - bgColor := t.Background() - textColor := t.Text() - textMutedColor := t.TextMuted() - - ta := textarea.New() - ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor) - ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor) - ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor) - ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor) - ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor) - ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor) - ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor) - ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor) - - ta.Prompt = " " - ta.ShowLineNumbers = false - ta.CharLimit = -1 - - if existing != nil { - ta.SetValue(existing.Value()) - ta.SetWidth(existing.Width()) - ta.SetHeight(existing.Height()) - } - - ta.Focus() - return ta -} - -func NewEditorCmp(app *app.App) tea.Model { - ta := CreateTextArea(nil) - return &editorCmp{ - app: app, - textarea: ta, - } -} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go new file mode 100644 index 0000000000000000000000000000000000000000..d0b936349db07598a33de8bd48238e16c6cb9524 --- /dev/null +++ b/internal/tui/components/chat/editor/editor.go @@ -0,0 +1,396 @@ +package editor + +import ( + "fmt" + "os" + "os/exec" + "slices" + "strings" + "unicode" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textarea" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/fileutil" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +type FileCompletionItem struct { + Path string // The file path +} + +type editorCmp struct { + width int + height int + x, y int + app *app.App + session session.Session + textarea textarea.Model + attachments []message.Attachment + deleteMode bool + + keyMap EditorKeyMap + + // File path completions + currentQuery string + completionsStartIndex int + isCompletionsOpen bool +} + +type DeleteAttachmentKeyMaps struct { + AttachmentDeleteMode key.Binding + Escape key.Binding + DeleteAllAttachments key.Binding +} + +var DeleteKeyMaps = DeleteAttachmentKeyMaps{ + AttachmentDeleteMode: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel delete mode"), + ), + DeleteAllAttachments: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("ctrl+r+r", "delete all attchments"), + ), +} + +const ( + maxAttachments = 5 +) + +func (m *editorCmp) openEditor() tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "nvim" + } + + tmpfile, err := os.CreateTemp("", "msg_*.md") + if err != nil { + return util.ReportError(err) + } + tmpfile.Close() + c := exec.Command(editor, tmpfile.Name()) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return tea.ExecProcess(c, func(err error) tea.Msg { + if err != nil { + return util.ReportError(err) + } + content, err := os.ReadFile(tmpfile.Name()) + if err != nil { + return util.ReportError(err) + } + if len(content) == 0 { + return util.ReportWarn("Message is empty") + } + os.Remove(tmpfile.Name()) + attachments := m.attachments + m.attachments = nil + return chat.SendMsg{ + Text: string(content), + Attachments: attachments, + } + }) +} + +func (m *editorCmp) Init() tea.Cmd { + return nil +} + +func (m *editorCmp) send() tea.Cmd { + if m.app.CoderAgent.IsSessionBusy(m.session.ID) { + return util.ReportWarn("Agent is working, please wait...") + } + + value := m.textarea.Value() + m.textarea.Reset() + attachments := m.attachments + + m.attachments = nil + if value == "" { + return nil + } + return tea.Batch( + util.CmdHandler(chat.SendMsg{ + Text: value, + Attachments: attachments, + }), + ) +} + +func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case chat.SessionSelectedMsg: + if msg.ID != m.session.ID { + m.session = msg + } + return m, nil + case filepicker.FilePickedMsg: + if len(m.attachments) >= maxAttachments { + logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments)) + return m, cmd + } + m.attachments = append(m.attachments, msg.Attachment) + return m, nil + case completions.CompletionsClosedMsg: + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + case completions.SelectCompletionMsg: + if !m.isCompletionsOpen { + return m, nil + } + if item, ok := msg.Value.(FileCompletionItem); ok { + // If the selected item is a file, insert its path into the textarea + value := m.textarea.Value() + value = value[:m.completionsStartIndex] + if len(value) > 0 && value[len(value)-1] != ' ' { + value += " " + } + value += item.Path + m.textarea.SetValue(value) + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + return m, nil + } + case tea.KeyPressMsg: + switch { + // Completions + case msg.String() == "/" && !m.isCompletionsOpen: + m.isCompletionsOpen = true + m.currentQuery = "" + cmds = append(cmds, m.startCompletions) + m.completionsStartIndex = len(m.textarea.Value()) + case msg.String() == "space" && m.isCompletionsOpen: + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex: + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + case msg.String() == "backspace" && m.isCompletionsOpen: + if len(m.currentQuery) > 0 { + m.currentQuery = m.currentQuery[:len(m.currentQuery)-1] + cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{ + Query: m.currentQuery, + })) + } else { + m.isCompletionsOpen = false + m.currentQuery = "" + m.completionsStartIndex = 0 + cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{})) + } + default: + if m.isCompletionsOpen { + m.currentQuery += msg.String() + cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{ + Query: m.currentQuery, + })) + } + } + if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) { + m.deleteMode = true + return m, nil + } + if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode { + m.deleteMode = false + m.attachments = nil + return m, nil + } + rune := msg.Code + if m.deleteMode && unicode.IsDigit(rune) { + num := int(rune - '0') + m.deleteMode = false + if num < 10 && len(m.attachments) > num { + if num == 0 { + m.attachments = m.attachments[num+1:] + } else { + m.attachments = slices.Delete(m.attachments, num, num+1) + } + return m, nil + } + } + if key.Matches(msg, m.keyMap.OpenEditor) { + if m.app.CoderAgent.IsSessionBusy(m.session.ID) { + return m, util.ReportWarn("Agent is working, please wait...") + } + return m, m.openEditor() + } + if key.Matches(msg, DeleteKeyMaps.Escape) { + m.deleteMode = false + return m, nil + } + // Hanlde Enter key + if m.textarea.Focused() && key.Matches(msg, m.keyMap.Send) { + value := m.textarea.Value() + if len(value) > 0 && value[len(value)-1] == '\\' { + // If the last character is a backslash, remove it and add a newline + m.textarea.SetValue(value[:len(value)-1] + "\n") + return m, nil + } else { + // Otherwise, send the message + return m, m.send() + } + } + } + m.textarea, cmd = m.textarea.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m *editorCmp) View() tea.View { + t := styles.CurrentTheme() + cursor := m.textarea.Cursor() + if cursor != nil { + cursor.X = cursor.X + m.x + 1 + cursor.Y = cursor.Y + m.y + 1 // adjust for padding + } + if len(m.attachments) == 0 { + content := t.S().Base.Padding(1).Render( + m.textarea.View(), + ) + view := tea.NewView(content) + view.SetCursor(cursor) + return view + } + content := t.S().Base.Padding(0, 1, 1, 1).Render( + lipgloss.JoinVertical(lipgloss.Top, + m.attachmentsContent(), + m.textarea.View(), + ), + ) + view := tea.NewView(content) + view.SetCursor(cursor) + return view +} + +func (m *editorCmp) SetSize(width, height int) tea.Cmd { + m.width = width + m.height = height + m.textarea.SetWidth(width - 2) // adjust for padding + m.textarea.SetHeight(height - 2) // adjust for padding + return nil +} + +func (m *editorCmp) GetSize() (int, int) { + return m.textarea.Width(), m.textarea.Height() +} + +func (m *editorCmp) attachmentsContent() string { + var styledAttachments []string + t := styles.CurrentTheme() + attachmentStyles := t.S().Base. + MarginLeft(1). + Background(t.FgMuted). + Foreground(t.FgBase) + for i, attachment := range m.attachments { + var filename string + if len(attachment.FileName) > 10 { + filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7]) + } else { + filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName) + } + if m.deleteMode { + filename = fmt.Sprintf("%d%s", i, filename) + } + styledAttachments = append(styledAttachments, attachmentStyles.Render(filename)) + } + content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...) + return content +} + +func (m *editorCmp) BindingKeys() []key.Binding { + bindings := []key.Binding{} + bindings = append(bindings, layout.KeyMapToSlice(m.keyMap)...) + bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...) + return bindings +} + +func (m *editorCmp) SetPosition(x, y int) tea.Cmd { + m.x = x + m.y = y + return nil +} + +func (m *editorCmp) startCompletions() tea.Msg { + files, _, _ := fileutil.ListDirectory(".", []string{}, 0) + completionItems := make([]completions.Completion, 0, len(files)) + for _, file := range files { + file = strings.TrimPrefix(file, "./") + completionItems = append(completionItems, completions.Completion{ + Title: file, + Value: FileCompletionItem{ + Path: file, + }, + }) + } + + x := m.textarea.Cursor().X + m.x + 1 + y := m.textarea.Cursor().Y + m.y + 1 + return completions.OpenCompletionsMsg{ + Completions: completionItems, + X: x, + Y: y, + } +} + +// Blur implements Container. +func (c *editorCmp) Blur() tea.Cmd { + c.textarea.Blur() + return nil +} + +// Focus implements Container. +func (c *editorCmp) Focus() tea.Cmd { + return c.textarea.Focus() +} + +// IsFocused implements Container. +func (c *editorCmp) IsFocused() bool { + return c.textarea.Focused() +} + +func NewEditorCmp(app *app.App) util.Model { + t := styles.CurrentTheme() + ta := textarea.New() + ta.SetStyles(t.S().TextArea) + ta.SetPromptFunc(4, func(lineIndex int, focused bool) string { + if lineIndex == 0 { + return " > " + } + if focused { + return t.S().Base.Foreground(t.GreenDark).Render("::: ") + } else { + return t.S().Muted.Render("::: ") + } + }) + ta.ShowLineNumbers = false + ta.CharLimit = -1 + ta.Placeholder = "Tell me more about this project..." + ta.SetVirtualCursor(false) + ta.Focus() + + return &editorCmp{ + app: app, + textarea: ta, + keyMap: DefaultEditorKeyMap(), + } +} diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..a53e18245cab3e330a54ebbf00d4b12ec3f4e7b7 --- /dev/null +++ b/internal/tui/components/chat/editor/keys.go @@ -0,0 +1,59 @@ +package editor + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type EditorKeyMap struct { + Send key.Binding + OpenEditor key.Binding +} + +func DefaultEditorKeyMap() EditorKeyMap { + return EditorKeyMap{ + Send: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "send"), + ), + OpenEditor: key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "open editor"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k EditorKeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k EditorKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Send, + k.OpenEditor, + } +} + +// TODO: update this to use the new keymap concepts +var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{ + AttachmentDeleteMode: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel delete mode"), + ), + DeleteAllAttachments: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("ctrl+r+r", "delete all attachments"), + ), +} diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go deleted file mode 100644 index 40d5b962876f09f60f44092f0c21b1f1ec9e4bb1..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/list.go +++ /dev/null @@ -1,487 +0,0 @@ -package chat - -import ( - "context" - "fmt" - "math" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/pubsub" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/components/dialog" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -type cacheItem struct { - width int - content []uiMessage -} -type messagesCmp struct { - app *app.App - width, height int - viewport viewport.Model - session session.Session - messages []message.Message - uiMessages []uiMessage - currentMsgID string - cachedContent map[string]cacheItem - spinner spinner.Model - rendering bool - attachments viewport.Model -} -type renderFinishedMsg struct{} - -type MessageKeys struct { - PageDown key.Binding - PageUp key.Binding - HalfPageUp key.Binding - HalfPageDown key.Binding -} - -var messageKeys = MessageKeys{ - PageDown: key.NewBinding( - key.WithKeys("pgdown"), - key.WithHelp("f/pgdn", "page down"), - ), - PageUp: key.NewBinding( - key.WithKeys("pgup"), - key.WithHelp("b/pgup", "page up"), - ), - HalfPageUp: key.NewBinding( - key.WithKeys("ctrl+u"), - key.WithHelp("ctrl+u", "½ page up"), - ), - HalfPageDown: key.NewBinding( - key.WithKeys("ctrl+d", "ctrl+d"), - key.WithHelp("ctrl+d", "½ page down"), - ), -} - -func (m *messagesCmp) Init() tea.Cmd { - return tea.Batch(m.viewport.Init(), m.spinner.Tick) -} - -func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case dialog.ThemeChangedMsg: - m.rerender() - return m, nil - case SessionSelectedMsg: - if msg.ID != m.session.ID { - cmd := m.SetSession(msg) - return m, cmd - } - return m, nil - case SessionClearedMsg: - m.session = session.Session{} - m.messages = make([]message.Message, 0) - m.currentMsgID = "" - m.rendering = false - return m, nil - - case tea.KeyMsg: - if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) || - key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) { - u, cmd := m.viewport.Update(msg) - m.viewport = u - cmds = append(cmds, cmd) - } - - case renderFinishedMsg: - m.rendering = false - m.viewport.GotoBottom() - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.session.ID { - m.session = msg.Payload - if m.session.SummaryMessageID == m.currentMsgID { - delete(m.cachedContent, m.currentMsgID) - m.renderView() - } - } - case pubsub.Event[message.Message]: - needsRerender := false - if msg.Type == pubsub.CreatedEvent { - if msg.Payload.SessionID == m.session.ID { - - messageExists := false - for _, v := range m.messages { - if v.ID == msg.Payload.ID { - messageExists = true - break - } - } - - if !messageExists { - if len(m.messages) > 0 { - lastMsgID := m.messages[len(m.messages)-1].ID - delete(m.cachedContent, lastMsgID) - } - - m.messages = append(m.messages, msg.Payload) - delete(m.cachedContent, m.currentMsgID) - m.currentMsgID = msg.Payload.ID - needsRerender = true - } - } - // There are tool calls from the child task - for _, v := range m.messages { - for _, c := range v.ToolCalls() { - if c.ID == msg.Payload.SessionID { - delete(m.cachedContent, v.ID) - needsRerender = true - } - } - } - } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID { - for i, v := range m.messages { - if v.ID == msg.Payload.ID { - m.messages[i] = msg.Payload - delete(m.cachedContent, msg.Payload.ID) - needsRerender = true - break - } - } - } - if needsRerender { - m.renderView() - if len(m.messages) > 0 { - if (msg.Type == pubsub.CreatedEvent) || - (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) { - m.viewport.GotoBottom() - } - } - } - } - - spinner, cmd := m.spinner.Update(msg) - m.spinner = spinner - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) -} - -func (m *messagesCmp) IsAgentWorking() bool { - return m.app.CoderAgent.IsSessionBusy(m.session.ID) -} - -func formatTimeDifference(unixTime1, unixTime2 int64) string { - diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1))) - - if diffSeconds < 60 { - return fmt.Sprintf("%.1fs", diffSeconds) - } - - minutes := int(diffSeconds / 60) - seconds := int(diffSeconds) % 60 - return fmt.Sprintf("%dm%ds", minutes, seconds) -} - -func (m *messagesCmp) renderView() { - m.uiMessages = make([]uiMessage, 0) - pos := 0 - baseStyle := styles.BaseStyle() - - if m.width == 0 { - return - } - for inx, msg := range m.messages { - switch msg.Role { - case message.User: - if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { - m.uiMessages = append(m.uiMessages, cache.content...) - continue - } - userMsg := renderUserMessage( - msg, - msg.ID == m.currentMsgID, - m.width, - pos, - ) - m.uiMessages = append(m.uiMessages, userMsg) - m.cachedContent[msg.ID] = cacheItem{ - width: m.width, - content: []uiMessage{userMsg}, - } - pos += userMsg.height + 1 // + 1 for spacing - case message.Assistant: - if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { - m.uiMessages = append(m.uiMessages, cache.content...) - continue - } - isSummary := m.session.SummaryMessageID == msg.ID - - assistantMessages := renderAssistantMessage( - msg, - inx, - m.messages, - m.app.Messages, - m.currentMsgID, - isSummary, - m.width, - pos, - ) - for _, msg := range assistantMessages { - m.uiMessages = append(m.uiMessages, msg) - pos += msg.height + 1 // + 1 for spacing - } - m.cachedContent[msg.ID] = cacheItem{ - width: m.width, - content: assistantMessages, - } - } - } - - messages := make([]string, 0) - for _, v := range m.uiMessages { - messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content), - baseStyle. - Width(m.width). - Render( - "", - ), - ) - } - - m.viewport.SetContent( - baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - messages..., - ), - ), - ) -} - -func (m *messagesCmp) View() string { - baseStyle := styles.BaseStyle() - - if m.rendering { - return baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - "Loading...", - m.working(), - m.help(), - ), - ) - } - if len(m.messages) == 0 { - content := baseStyle. - Width(m.width). - Height(m.height - 1). - Render( - m.initialScreen(), - ) - - return baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - content, - "", - m.help(), - ), - ) - } - - return baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - m.viewport.View(), - m.working(), - m.help(), - ), - ) -} - -func hasToolsWithoutResponse(messages []message.Message) bool { - toolCalls := make([]message.ToolCall, 0) - toolResults := make([]message.ToolResult, 0) - for _, m := range messages { - toolCalls = append(toolCalls, m.ToolCalls()...) - toolResults = append(toolResults, m.ToolResults()...) - } - - for _, v := range toolCalls { - found := false - for _, r := range toolResults { - if v.ID == r.ToolCallID { - found = true - break - } - } - if !found && v.Finished { - return true - } - } - return false -} - -func hasUnfinishedToolCalls(messages []message.Message) bool { - toolCalls := make([]message.ToolCall, 0) - for _, m := range messages { - toolCalls = append(toolCalls, m.ToolCalls()...) - } - for _, v := range toolCalls { - if !v.Finished { - return true - } - } - return false -} - -func (m *messagesCmp) working() string { - text := "" - if m.IsAgentWorking() && len(m.messages) > 0 { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - task := "Thinking..." - lastMessage := m.messages[len(m.messages)-1] - if hasToolsWithoutResponse(m.messages) { - task = "Waiting for tool response..." - } else if hasUnfinishedToolCalls(m.messages) { - task = "Building tool call..." - } else if !lastMessage.IsFinished() { - task = "Generating..." - } - if task != "" { - text += baseStyle. - Width(m.width). - Foreground(t.Primary()). - Bold(true). - Render(fmt.Sprintf("%s %s ", m.spinner.View(), task)) - } - } - return text -} - -func (m *messagesCmp) help() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - text := "" - - if m.app.CoderAgent.IsBusy() { - text += lipgloss.JoinHorizontal( - lipgloss.Left, - baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "), - baseStyle.Foreground(t.Text()).Bold(true).Render("esc"), - baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"), - ) - } else { - text += lipgloss.JoinHorizontal( - lipgloss.Left, - baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "), - baseStyle.Foreground(t.Text()).Bold(true).Render("enter"), - baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"), - baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"), - baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"), - baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"), - ) - } - return baseStyle. - Width(m.width). - Render(text) -} - -func (m *messagesCmp) initialScreen() string { - baseStyle := styles.BaseStyle() - - return baseStyle.Width(m.width).Render( - lipgloss.JoinVertical( - lipgloss.Top, - header(m.width), - "", - lspsConfigured(m.width), - ), - ) -} - -func (m *messagesCmp) rerender() { - for _, msg := range m.messages { - delete(m.cachedContent, msg.ID) - } - m.renderView() -} - -func (m *messagesCmp) SetSize(width, height int) tea.Cmd { - if m.width == width && m.height == height { - return nil - } - m.width = width - m.height = height - m.viewport.Width = width - m.viewport.Height = height - 2 - m.attachments.Width = width + 40 - m.attachments.Height = 3 - m.rerender() - return nil -} - -func (m *messagesCmp) GetSize() (int, int) { - return m.width, m.height -} - -func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { - if m.session.ID == session.ID { - return nil - } - m.session = session - messages, err := m.app.Messages.List(context.Background(), session.ID) - if err != nil { - return util.ReportError(err) - } - m.messages = messages - if len(m.messages) > 0 { - m.currentMsgID = m.messages[len(m.messages)-1].ID - } - delete(m.cachedContent, m.currentMsgID) - m.rendering = true - return func() tea.Msg { - m.renderView() - return renderFinishedMsg{} - } -} - -func (m *messagesCmp) BindingKeys() []key.Binding { - return []key.Binding{ - m.viewport.KeyMap.PageDown, - m.viewport.KeyMap.PageUp, - m.viewport.KeyMap.HalfPageUp, - m.viewport.KeyMap.HalfPageDown, - } -} - -func NewMessagesCmp(app *app.App) tea.Model { - s := spinner.New() - s.Spinner = spinner.Pulse - vp := viewport.New(0, 0) - attachmets := viewport.New(0, 0) - vp.KeyMap.PageUp = messageKeys.PageUp - vp.KeyMap.PageDown = messageKeys.PageDown - vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp - vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown - return &messagesCmp{ - app: app, - cachedContent: make(map[string]cacheItem), - viewport: vp, - spinner: s, - attachments: attachmets, - } -} diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go deleted file mode 100644 index 0732366d94c01dc8d183f97dcebbe8a220554f74..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/message.go +++ /dev/null @@ -1,659 +0,0 @@ -package chat - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/diff" - "github.com/opencode-ai/opencode/internal/llm/agent" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" -) - -type uiMessageType int - -const ( - userMessageType uiMessageType = iota - assistantMessageType - toolMessageType - - maxResultHeight = 10 -) - -type uiMessage struct { - ID string - messageType uiMessageType - position int - height int - content string -} - -func toMarkdown(content string, focused bool, width int) string { - r := styles.GetMarkdownRenderer(width) - rendered, _ := r.Render(content) - return rendered -} - -func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string { - t := theme.CurrentTheme() - - style := styles.BaseStyle(). - Width(width - 1). - BorderLeft(true). - Foreground(t.TextMuted()). - BorderForeground(t.Primary()). - BorderStyle(lipgloss.ThickBorder()) - - if isUser { - style = style.BorderForeground(t.Secondary()) - } - - // Apply markdown formatting and handle background color - parts := []string{ - styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()), - } - - // Remove newline at the end - parts[0] = strings.TrimSuffix(parts[0], "\n") - if len(info) > 0 { - parts = append(parts, info...) - } - - rendered := style.Render( - lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ), - ) - - return rendered -} - -func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage { - var styledAttachments []string - t := theme.CurrentTheme() - attachmentStyles := styles.BaseStyle(). - MarginLeft(1). - Background(t.TextMuted()). - Foreground(t.Text()) - for _, attachment := range msg.BinaryContent() { - file := filepath.Base(attachment.Path) - var filename string - if len(file) > 10 { - filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7]) - } else { - filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file) - } - styledAttachments = append(styledAttachments, attachmentStyles.Render(filename)) - } - content := "" - if len(styledAttachments) > 0 { - attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)) - content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent) - } else { - content = renderMessage(msg.Content().String(), true, isFocused, width) - } - userMsg := uiMessage{ - ID: msg.ID, - messageType: userMessageType, - position: position, - height: lipgloss.Height(content), - content: content, - } - return userMsg -} - -// Returns multiple uiMessages because of the tool calls -func renderAssistantMessage( - msg message.Message, - msgIndex int, - allMessages []message.Message, // we need this to get tool results and the user message - messagesService message.Service, // We need this to get the task tool messages - focusedUIMessageId string, - isSummary bool, - width int, - position int, -) []uiMessage { - messages := []uiMessage{} - content := msg.Content().String() - thinking := msg.IsThinking() - thinkingContent := msg.ReasoningContent().Thinking - finished := msg.IsFinished() - finishData := msg.FinishPart() - info := []string{} - - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - // Add finish info if available - if finished { - switch finishData.Reason { - case message.FinishReasonEndTurn: - took := formatTimestampDiff(msg.CreatedAt, finishData.Time) - info = append(info, baseStyle. - Width(width-1). - Foreground(t.TextMuted()). - Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took)), - ) - case message.FinishReasonCanceled: - info = append(info, baseStyle. - Width(width-1). - Foreground(t.TextMuted()). - Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled")), - ) - case message.FinishReasonError: - info = append(info, baseStyle. - Width(width-1). - Foreground(t.TextMuted()). - Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error")), - ) - case message.FinishReasonPermissionDenied: - info = append(info, baseStyle. - Width(width-1). - Foreground(t.TextMuted()). - Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied")), - ) - } - } - if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) { - if content == "" { - content = "*Finished without output*" - } - if isSummary { - info = append(info, baseStyle.Width(width-1).Foreground(t.TextMuted()).Render(" (summary)")) - } - - content = renderMessage(content, false, true, width, info...) - messages = append(messages, uiMessage{ - ID: msg.ID, - messageType: assistantMessageType, - position: position, - height: lipgloss.Height(content), - content: content, - }) - position += messages[0].height - position++ // for the space - } else if thinking && thinkingContent != "" { - // Render the thinking content - content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width) - } - - for i, toolCall := range msg.ToolCalls() { - toolCallContent := renderToolMessage( - toolCall, - allMessages, - messagesService, - focusedUIMessageId, - false, - width, - i+1, - ) - messages = append(messages, toolCallContent) - position += toolCallContent.height - position++ // for the space - } - return messages -} - -func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult { - for _, msg := range futureMessages { - for _, result := range msg.ToolResults() { - if result.ToolCallID == toolCallID { - return &result - } - } - } - return nil -} - -func toolName(name string) string { - switch name { - case agent.AgentToolName: - return "Task" - case tools.BashToolName: - return "Bash" - case tools.EditToolName: - return "Edit" - case tools.FetchToolName: - return "Fetch" - case tools.GlobToolName: - return "Glob" - case tools.GrepToolName: - return "Grep" - case tools.LSToolName: - return "List" - case tools.SourcegraphToolName: - return "Sourcegraph" - case tools.ViewToolName: - return "View" - case tools.WriteToolName: - return "Write" - case tools.PatchToolName: - return "Patch" - } - return name -} - -func getToolAction(name string) string { - switch name { - case agent.AgentToolName: - return "Preparing prompt..." - case tools.BashToolName: - return "Building command..." - case tools.EditToolName: - return "Preparing edit..." - case tools.FetchToolName: - return "Writing fetch..." - case tools.GlobToolName: - return "Finding files..." - case tools.GrepToolName: - return "Searching content..." - case tools.LSToolName: - return "Listing directory..." - case tools.SourcegraphToolName: - return "Searching code..." - case tools.ViewToolName: - return "Reading file..." - case tools.WriteToolName: - return "Preparing write..." - case tools.PatchToolName: - return "Preparing patch..." - } - return "Working..." -} - -// renders params, params[0] (params[1]=params[2] ....) -func renderParams(paramsWidth int, params ...string) string { - if len(params) == 0 { - return "" - } - mainParam := params[0] - if len(mainParam) > paramsWidth { - mainParam = mainParam[:paramsWidth-3] + "..." - } - - if len(params) == 1 { - return mainParam - } - otherParams := params[1:] - // create pairs of key/value - // if odd number of params, the last one is a key without value - if len(otherParams)%2 != 0 { - otherParams = append(otherParams, "") - } - parts := make([]string, 0, len(otherParams)/2) - for i := 0; i < len(otherParams); i += 2 { - key := otherParams[i] - value := otherParams[i+1] - if value == "" { - continue - } - parts = append(parts, fmt.Sprintf("%s=%s", key, value)) - } - - partsRendered := strings.Join(parts, ", ") - remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space - if remainingWidth < 30 { - // No space for the params, just show the main - return mainParam - } - - if len(parts) > 0 { - mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", ")) - } - - return ansi.Truncate(mainParam, paramsWidth, "...") -} - -func removeWorkingDirPrefix(path string) string { - wd := config.WorkingDirectory() - if strings.HasPrefix(path, wd) { - path = strings.TrimPrefix(path, wd) - } - if strings.HasPrefix(path, "/") { - path = strings.TrimPrefix(path, "/") - } - if strings.HasPrefix(path, "./") { - path = strings.TrimPrefix(path, "./") - } - if strings.HasPrefix(path, "../") { - path = strings.TrimPrefix(path, "../") - } - return path -} - -func renderToolParams(paramWidth int, toolCall message.ToolCall) string { - params := "" - switch toolCall.Name { - case agent.AgentToolName: - var params agent.AgentParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - prompt := strings.ReplaceAll(params.Prompt, "\n", " ") - return renderParams(paramWidth, prompt) - case tools.BashToolName: - var params tools.BashParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - command := strings.ReplaceAll(params.Command, "\n", " ") - return renderParams(paramWidth, command) - case tools.EditToolName: - var params tools.EditParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - filePath := removeWorkingDirPrefix(params.FilePath) - return renderParams(paramWidth, filePath) - case tools.FetchToolName: - var params tools.FetchParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - url := params.URL - toolParams := []string{ - url, - } - if params.Format != "" { - toolParams = append(toolParams, "format", params.Format) - } - if params.Timeout != 0 { - toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String()) - } - return renderParams(paramWidth, toolParams...) - case tools.GlobToolName: - var params tools.GlobParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - pattern := params.Pattern - toolParams := []string{ - pattern, - } - if params.Path != "" { - toolParams = append(toolParams, "path", params.Path) - } - return renderParams(paramWidth, toolParams...) - case tools.GrepToolName: - var params tools.GrepParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - pattern := params.Pattern - toolParams := []string{ - pattern, - } - if params.Path != "" { - toolParams = append(toolParams, "path", params.Path) - } - if params.Include != "" { - toolParams = append(toolParams, "include", params.Include) - } - if params.LiteralText { - toolParams = append(toolParams, "literal", "true") - } - return renderParams(paramWidth, toolParams...) - case tools.LSToolName: - var params tools.LSParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - path := params.Path - if path == "" { - path = "." - } - return renderParams(paramWidth, path) - case tools.SourcegraphToolName: - var params tools.SourcegraphParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - return renderParams(paramWidth, params.Query) - case tools.ViewToolName: - var params tools.ViewParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - filePath := removeWorkingDirPrefix(params.FilePath) - toolParams := []string{ - filePath, - } - if params.Limit != 0 { - toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit)) - } - if params.Offset != 0 { - toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) - } - return renderParams(paramWidth, toolParams...) - case tools.WriteToolName: - var params tools.WriteParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - filePath := removeWorkingDirPrefix(params.FilePath) - return renderParams(paramWidth, filePath) - default: - input := strings.ReplaceAll(toolCall.Input, "\n", " ") - params = renderParams(paramWidth, input) - } - return params -} - -func truncateHeight(content string, height int) string { - lines := strings.Split(content, "\n") - if len(lines) > height { - return strings.Join(lines[:height], "\n") - } - return content -} - -func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - if response.IsError { - errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " ")) - errContent = ansi.Truncate(errContent, width-1, "...") - return baseStyle. - Width(width). - Foreground(t.Error()). - Render(errContent) - } - - resultContent := truncateHeight(response.Content, maxResultHeight) - switch toolCall.Name { - case agent.AgentToolName: - return styles.ForceReplaceBackgroundWithLipgloss( - toMarkdown(resultContent, false, width), - t.Background(), - ) - case tools.BashToolName: - resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent) - return styles.ForceReplaceBackgroundWithLipgloss( - toMarkdown(resultContent, true, width), - t.Background(), - ) - case tools.EditToolName: - metadata := tools.EditResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - truncDiff := truncateHeight(metadata.Diff, maxResultHeight) - formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width)) - return formattedDiff - case tools.FetchToolName: - var params tools.FetchParams - json.Unmarshal([]byte(toolCall.Input), ¶ms) - mdFormat := "markdown" - switch params.Format { - case "text": - mdFormat = "text" - case "html": - mdFormat = "html" - } - resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent) - return styles.ForceReplaceBackgroundWithLipgloss( - toMarkdown(resultContent, true, width), - t.Background(), - ) - case tools.GlobToolName: - return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) - case tools.GrepToolName: - return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) - case tools.LSToolName: - return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) - case tools.SourcegraphToolName: - return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent) - case tools.ViewToolName: - metadata := tools.ViewResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - ext := filepath.Ext(metadata.FilePath) - if ext == "" { - ext = "" - } else { - ext = strings.ToLower(ext[1:]) - } - resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight)) - return styles.ForceReplaceBackgroundWithLipgloss( - toMarkdown(resultContent, true, width), - t.Background(), - ) - case tools.WriteToolName: - params := tools.WriteParams{} - json.Unmarshal([]byte(toolCall.Input), ¶ms) - metadata := tools.WriteResponseMetadata{} - json.Unmarshal([]byte(response.Metadata), &metadata) - ext := filepath.Ext(params.FilePath) - if ext == "" { - ext = "" - } else { - ext = strings.ToLower(ext[1:]) - } - resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight)) - return styles.ForceReplaceBackgroundWithLipgloss( - toMarkdown(resultContent, true, width), - t.Background(), - ) - default: - resultContent = fmt.Sprintf("```text\n%s\n```", resultContent) - return styles.ForceReplaceBackgroundWithLipgloss( - toMarkdown(resultContent, true, width), - t.Background(), - ) - } -} - -func renderToolMessage( - toolCall message.ToolCall, - allMessages []message.Message, - messagesService message.Service, - focusedUIMessageId string, - nested bool, - width int, - position int, -) uiMessage { - if nested { - width = width - 3 - } - - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - style := baseStyle. - Width(width - 1). - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1). - BorderForeground(t.TextMuted()) - - response := findToolResponse(toolCall.ID, allMessages) - toolNameText := baseStyle.Foreground(t.TextMuted()). - Render(fmt.Sprintf("%s: ", toolName(toolCall.Name))) - - if !toolCall.Finished { - // Get a brief description of what the tool is doing - toolAction := getToolAction(toolCall.Name) - - progressText := baseStyle. - Width(width - 2 - lipgloss.Width(toolNameText)). - Foreground(t.TextMuted()). - Render(fmt.Sprintf("%s", toolAction)) - - content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText)) - toolMsg := uiMessage{ - messageType: toolMessageType, - position: position, - height: lipgloss.Height(content), - content: content, - } - return toolMsg - } - - params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall) - responseContent := "" - if response != nil { - responseContent = renderToolResponse(toolCall, *response, width-2) - responseContent = strings.TrimSuffix(responseContent, "\n") - } else { - responseContent = baseStyle. - Italic(true). - Width(width - 2). - Foreground(t.TextMuted()). - Render("Waiting for response...") - } - - parts := []string{} - if !nested { - formattedParams := baseStyle. - Width(width - 2 - lipgloss.Width(toolNameText)). - Foreground(t.TextMuted()). - Render(params) - - parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams)) - } else { - prefix := baseStyle. - Foreground(t.TextMuted()). - Render(" └ ") - formattedParams := baseStyle. - Width(width - 2 - lipgloss.Width(toolNameText)). - Foreground(t.TextMuted()). - Render(params) - parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams)) - } - - if toolCall.Name == agent.AgentToolName { - taskMessages, _ := messagesService.List(context.Background(), toolCall.ID) - toolCalls := []message.ToolCall{} - for _, v := range taskMessages { - toolCalls = append(toolCalls, v.ToolCalls()...) - } - for _, call := range toolCalls { - rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0) - parts = append(parts, rendered.content) - } - } - if responseContent != "" && !nested { - parts = append(parts, responseContent) - } - - content := style.Render( - lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ), - ) - if nested { - content = lipgloss.JoinVertical( - lipgloss.Left, - parts..., - ) - } - toolMsg := uiMessage{ - messageType: toolMessageType, - position: position, - height: lipgloss.Height(content), - content: content, - } - return toolMsg -} - -// Helper function to format the time difference between two Unix timestamps -func formatTimestampDiff(start, end int64) string { - diffSeconds := float64(end-start) / 1000.0 // Convert to seconds - if diffSeconds < 1 { - return fmt.Sprintf("%dms", int(diffSeconds*1000)) - } - if diffSeconds < 60 { - return fmt.Sprintf("%.1fs", diffSeconds) - } - return fmt.Sprintf("%.1fm", diffSeconds/60) -} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go new file mode 100644 index 0000000000000000000000000000000000000000..f35e1af9dd542f6225d7adad8e26256d55a9e919 --- /dev/null +++ b/internal/tui/components/chat/messages/messages.go @@ -0,0 +1,289 @@ +package messages + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/v2/spinner" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/lipgloss/v2" + + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/tui/components/anim" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" +) + +// MessageCmp defines the interface for message components in the chat interface. +// It combines standard UI model interfaces with message-specific functionality. +type MessageCmp interface { + util.Model // Basic Bubble Tea model interface + layout.Sizeable // Width/height management + layout.Focusable // Focus state management + GetMessage() message.Message // Access to underlying message data + Spinning() bool // Animation state for loading messages +} + +// messageCmp implements the MessageCmp interface for displaying chat messages. +// It handles rendering of user and assistant messages with proper styling, +// animations, and state management. +type messageCmp struct { + width int // Component width for text wrapping + focused bool // Focus state for border styling + + // Core message data and state + message message.Message // The underlying message content + spinning bool // Whether to show loading animation + anim util.Model // Animation component for loading states + lastUserMessageTime time.Time // Used for calculating response duration +} + +// MessageOption provides functional options for configuring message components +type MessageOption func(*messageCmp) + +// WithLastUserMessageTime sets the timestamp of the last user message +// for calculating assistant response duration +func WithLastUserMessageTime(t time.Time) MessageOption { + return func(m *messageCmp) { + m.lastUserMessageTime = t + } +} + +// NewMessageCmp creates a new message component with the given message and options +func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp { + m := &messageCmp{ + message: msg, + anim: anim.New(15, ""), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// Init initializes the message component and starts animations if needed. +// Returns a command to start the animation for spinning messages. +func (m *messageCmp) Init() tea.Cmd { + m.spinning = m.shouldSpin() + if m.spinning { + return m.anim.Init() + } + return nil +} + +// Update handles incoming messages and updates the component state. +// Manages animation updates for spinning messages and stops animation when appropriate. +func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg: + m.spinning = m.shouldSpin() + if m.spinning { + u, cmd := m.anim.Update(msg) + m.anim = u.(util.Model) + return m, cmd + } + } + return m, nil +} + +// View renders the message component based on its current state. +// Returns different views for spinning, user, and assistant messages. +func (m *messageCmp) View() tea.View { + if m.spinning { + return tea.NewView(m.style().PaddingLeft(1).Render(m.anim.View().String())) + } + if m.message.ID != "" { + // this is a user or assistant message + switch m.message.Role { + case message.User: + return tea.NewView(m.renderUserMessage()) + default: + return tea.NewView(m.renderAssistantMessage()) + } + } + return tea.NewView(m.style().Render("No message content")) +} + +// GetMessage returns the underlying message data +func (m *messageCmp) GetMessage() message.Message { + return m.message +} + +// textWidth calculates the available width for text content, +// accounting for borders and padding +func (m *messageCmp) textWidth() int { + return m.width - 2 // take into account the border and/or padding +} + +// style returns the lipgloss style for the message component. +// Applies different border colors and styles based on message role and focus state. +func (msg *messageCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + borderStyle := lipgloss.NormalBorder() + if msg.focused { + borderStyle = lipgloss.ThickBorder() + } + + style := t.S().Text + if msg.message.Role == message.User { + style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary) + } else { + if msg.focused { + style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark) + } else { + style = style.PaddingLeft(2) + } + } + return style +} + +// renderAssistantMessage renders assistant messages with optional footer information. +// Shows model name, response time, and finish reason when the message is complete. +func (m *messageCmp) renderAssistantMessage() string { + t := styles.CurrentTheme() + parts := []string{ + m.markdownContent(), + } + + finished := m.message.IsFinished() + finishData := m.message.FinishPart() + // Only show the footer if the message is not a tool call + if finished && finishData.Reason != message.FinishReasonToolUse { + infoMsg := "" + switch finishData.Reason { + case message.FinishReasonEndTurn: + finishTime := time.Unix(finishData.Time, 0) + duration := finishTime.Sub(m.lastUserMessageTime) + infoMsg = duration.String() + case message.FinishReasonCanceled: + infoMsg = "canceled" + case message.FinishReasonError: + infoMsg = "error" + case message.FinishReasonPermissionDenied: + infoMsg = "permission denied" + } + assistant := t.S().Muted.Render(fmt.Sprintf("⬡ %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg)) + parts = append(parts, core.Section(assistant, m.textWidth())) + } + + joined := lipgloss.JoinVertical(lipgloss.Left, parts...) + return m.style().Render(joined) +} + +// renderUserMessage renders user messages with file attachments. +// Displays message content and any attached files with appropriate icons. +func (m *messageCmp) renderUserMessage() string { + t := styles.CurrentTheme() + parts := []string{ + m.markdownContent(), + } + attachmentStyles := t.S().Text. + MarginLeft(1). + Background(t.BgSubtle) + attachments := []string{} + for _, attachment := range m.message.BinaryContent() { + file := filepath.Base(attachment.Path) + var filename string + if len(file) > 10 { + filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7]) + } else { + filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file) + } + attachments = append(attachments, attachmentStyles.Render(filename)) + } + if len(attachments) > 0 { + parts = append(parts, "", strings.Join(attachments, "")) + } + joined := lipgloss.JoinVertical(lipgloss.Left, parts...) + return m.style().MarginBottom(1).Render(joined) +} + +// toMarkdown converts text content to rendered markdown using the configured renderer +func (m *messageCmp) toMarkdown(content string) string { + r := styles.GetMarkdownRenderer(m.textWidth()) + rendered, _ := r.Render(content) + return strings.TrimSuffix(rendered, "\n") +} + +// markdownContent processes the message content and handles special states. +// Returns appropriate content for thinking, finished, and error states. +func (m *messageCmp) markdownContent() string { + content := m.message.Content().String() + if m.message.Role == message.Assistant { + thinking := m.message.IsThinking() + finished := m.message.IsFinished() + finishedData := m.message.FinishPart() + if thinking { + // Handle the thinking state + // TODO: maybe add the thinking content if available later. + content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...") + } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn { + // Sometimes the LLMs respond with no content when they think the previous tool result + // provides the requested question + content = "*Finished without output*" + } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled { + content = "*Canceled*" + } + } + return m.toMarkdown(content) +} + +// shouldSpin determines whether the message should show a loading animation. +// Only assistant messages without content that aren't finished should spin. +func (m *messageCmp) shouldSpin() bool { + if m.message.Role != message.Assistant { + return false + } + + if m.message.IsFinished() { + return false + } + + if m.message.Content().Text != "" { + return false + } + return true +} + +// Focus management methods + +// Blur removes focus from the message component +func (m *messageCmp) Blur() tea.Cmd { + m.focused = false + return nil +} + +// Focus sets focus on the message component +func (m *messageCmp) Focus() tea.Cmd { + m.focused = true + return nil +} + +// IsFocused returns whether the message component is currently focused +func (m *messageCmp) IsFocused() bool { + return m.focused +} + +// Size management methods + +// GetSize returns the current dimensions of the message component +func (m *messageCmp) GetSize() (int, int) { + return m.width, 0 +} + +// SetSize updates the width of the message component for text wrapping +func (m *messageCmp) SetSize(width int, height int) tea.Cmd { + // For better readability, we limit the width to a maximum of 120 characters + m.width = min(width, 120) + return nil +} + +// Spinning returns whether the message is currently showing a loading animation +func (m *messageCmp) Spinning() bool { + return m.spinning +} diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go new file mode 100644 index 0000000000000000000000000000000000000000..339aa51b299d368a5d8f3c31b0c10d6d00a8a784 --- /dev/null +++ b/internal/tui/components/chat/messages/renderer.go @@ -0,0 +1,719 @@ +package messages + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/fileutil" + "github.com/charmbracelet/crush/internal/highlight" + "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/lipgloss/v2/tree" + "github.com/charmbracelet/x/ansi" +) + +// responseContextHeight limits the number of lines displayed in tool output +const responseContextHeight = 10 + +// renderer defines the interface for tool-specific rendering implementations +type renderer interface { + // Render returns the complete (already styled) tool‑call view, not + // including the outer border. + Render(v *toolCallCmp) string +} + +// rendererFactory creates new renderer instances +type rendererFactory func() renderer + +// renderRegistry manages the mapping of tool names to their renderers +type renderRegistry map[string]rendererFactory + +// register adds a new renderer factory to the registry +func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f } + +// lookup retrieves a renderer for the given tool name, falling back to generic renderer +func (rr renderRegistry) lookup(name string) renderer { + if f, ok := rr[name]; ok { + return f() + } + return genericRenderer{} // sensible fallback +} + +// registry holds all registered tool renderers +var registry = renderRegistry{} + +// baseRenderer provides common functionality for all tool renderers +type baseRenderer struct{} + +// paramBuilder helps construct parameter lists for tool headers +type paramBuilder struct { + args []string +} + +// newParamBuilder creates a new parameter builder +func newParamBuilder() *paramBuilder { + return ¶mBuilder{args: make([]string, 0)} +} + +// addMain adds the main parameter (first argument) +func (pb *paramBuilder) addMain(value string) *paramBuilder { + if value != "" { + pb.args = append(pb.args, value) + } + return pb +} + +// addKeyValue adds a key-value pair parameter +func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder { + if value != "" { + pb.args = append(pb.args, key, value) + } + return pb +} + +// addFlag adds a boolean flag parameter +func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder { + if value { + pb.args = append(pb.args, key, "true") + } + return pb +} + +// build returns the final parameter list +func (pb *paramBuilder) build() []string { + return pb.args +} + +// renderWithParams provides a common rendering pattern for tools with parameters +func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string { + width := v.textWidth() + if v.isNested { + width -= 4 // Adjust for nested tool call indentation + } + header := br.makeHeader(v, toolName, width, args...) + if v.isNested { + return v.style().Render(header) + } + if res, done := earlyState(header, v); done { + return res + } + body := contentRenderer() + return joinHeaderBody(header, body) +} + +// unmarshalParams safely unmarshal JSON parameters +func (br baseRenderer) unmarshalParams(input string, target any) error { + return json.Unmarshal([]byte(input), target) +} + +// makeHeader builds ": param (key=value)" and truncates as needed. +func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string { + t := styles.CurrentTheme() + icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) + if v.result.ToolCallID != "" { + if v.result.IsError { + icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError) + } else { + icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess) + } + } else if v.cancelled { + icon = t.S().Muted.Render(styles.ToolPending) + } + tool = t.S().Base.Foreground(t.Blue).Render(tool) + prefix := fmt.Sprintf("%s %s: ", icon, tool) + return prefix + renderParamList(width-lipgloss.Width(prefix), params...) +} + +// renderError provides consistent error rendering +func (br baseRenderer) renderError(v *toolCallCmp, message string) string { + t := styles.CurrentTheme() + header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "") + message = t.S().Error.Render(v.fit(message, v.textWidth()-2)) // -2 for padding + return joinHeaderBody(header, message) +} + +// Register tool renderers +func init() { + registry.register(tools.BashToolName, func() renderer { return bashRenderer{} }) + registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} }) + registry.register(tools.EditToolName, func() renderer { return editRenderer{} }) + registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} }) + registry.register(tools.FetchToolName, func() renderer { return fetchRenderer{} }) + registry.register(tools.GlobToolName, func() renderer { return globRenderer{} }) + registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} }) + registry.register(tools.LSToolName, func() renderer { return lsRenderer{} }) + registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} }) + registry.register(tools.PatchToolName, func() renderer { return patchRenderer{} }) + registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} }) + registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} }) +} + +// ----------------------------------------------------------------------------- +// Generic renderer +// ----------------------------------------------------------------------------- + +// genericRenderer handles unknown tool types with basic parameter display +type genericRenderer struct { + baseRenderer +} + +// Render displays the tool call with its raw input and plain content output +func (gr genericRenderer) Render(v *toolCallCmp) string { + return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string { + return renderPlainContent(v, v.result.Content) + }) +} + +// ----------------------------------------------------------------------------- +// Bash renderer +// ----------------------------------------------------------------------------- + +// bashRenderer handles bash command execution display +type bashRenderer struct { + baseRenderer +} + +// Render displays the bash command with sanitized newlines and plain output +func (br bashRenderer) Render(v *toolCallCmp) string { + var params tools.BashParams + if err := br.unmarshalParams(v.call.Input, ¶ms); err != nil { + return br.renderError(v, "Invalid bash parameters") + } + + cmd := strings.ReplaceAll(params.Command, "\n", " ") + args := newParamBuilder().addMain(cmd).build() + + return br.renderWithParams(v, "Bash", args, func() string { + return renderPlainContent(v, v.result.Content) + }) +} + +// ----------------------------------------------------------------------------- +// View renderer +// ----------------------------------------------------------------------------- + +// viewRenderer handles file viewing with syntax highlighting and line numbers +type viewRenderer struct { + baseRenderer +} + +// Render displays file content with optional limit and offset parameters +func (vr viewRenderer) Render(v *toolCallCmp) string { + var params tools.ViewParams + if err := vr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return vr.renderError(v, "Invalid view parameters") + } + + file := fileutil.PrettyPath(params.FilePath) + args := newParamBuilder(). + addMain(file). + addKeyValue("limit", formatNonZero(params.Limit)). + addKeyValue("offset", formatNonZero(params.Offset)). + build() + + return vr.renderWithParams(v, "View", args, func() string { + var meta tools.ViewResponseMetadata + if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil { + return renderPlainContent(v, v.result.Content) + } + return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset) + }) +} + +// formatNonZero returns string representation of non-zero integers, empty string for zero +func formatNonZero(value int) string { + if value == 0 { + return "" + } + return fmt.Sprintf("%d", value) +} + +// ----------------------------------------------------------------------------- +// Edit renderer +// ----------------------------------------------------------------------------- + +// editRenderer handles file editing with diff visualization +type editRenderer struct { + baseRenderer +} + +// Render displays the edited file with a formatted diff of changes +func (er editRenderer) Render(v *toolCallCmp) string { + var params tools.EditParams + if err := er.unmarshalParams(v.call.Input, ¶ms); err != nil { + return er.renderError(v, "Invalid edit parameters") + } + + file := fileutil.PrettyPath(params.FilePath) + args := newParamBuilder().addMain(file).build() + + return er.renderWithParams(v, "Edit", args, func() string { + var meta tools.EditResponseMetadata + if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil { + return renderPlainContent(v, v.result.Content) + } + + trunc := truncateHeight(meta.Diff, responseContextHeight) + diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()-2)) + return diffView + }) +} + +// ----------------------------------------------------------------------------- +// Write renderer +// ----------------------------------------------------------------------------- + +// writeRenderer handles file writing with syntax-highlighted content preview +type writeRenderer struct { + baseRenderer +} + +// Render displays the file being written with syntax highlighting +func (wr writeRenderer) Render(v *toolCallCmp) string { + var params tools.WriteParams + if err := wr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return wr.renderError(v, "Invalid write parameters") + } + + file := fileutil.PrettyPath(params.FilePath) + args := newParamBuilder().addMain(file).build() + + return wr.renderWithParams(v, "Write", args, func() string { + return renderCodeContent(v, file, params.Content, 0) + }) +} + +// ----------------------------------------------------------------------------- +// Fetch renderer +// ----------------------------------------------------------------------------- + +// fetchRenderer handles URL fetching with format-specific content display +type fetchRenderer struct { + baseRenderer +} + +// Render displays the fetched URL with format and timeout parameters +func (fr fetchRenderer) Render(v *toolCallCmp) string { + var params tools.FetchParams + if err := fr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return fr.renderError(v, "Invalid fetch parameters") + } + + args := newParamBuilder(). + addMain(params.URL). + addKeyValue("format", params.Format). + addKeyValue("timeout", formatTimeout(params.Timeout)). + build() + + return fr.renderWithParams(v, "Fetch", args, func() string { + file := fr.getFileExtension(params.Format) + return renderCodeContent(v, file, v.result.Content, 0) + }) +} + +// getFileExtension returns appropriate file extension for syntax highlighting +func (fr fetchRenderer) getFileExtension(format string) string { + switch format { + case "text": + return "fetch.txt" + case "html": + return "fetch.html" + default: + return "fetch.md" + } +} + +// formatTimeout converts timeout seconds to duration string +func formatTimeout(timeout int) string { + if timeout == 0 { + return "" + } + return (time.Duration(timeout) * time.Second).String() +} + +// ----------------------------------------------------------------------------- +// Glob renderer +// ----------------------------------------------------------------------------- + +// globRenderer handles file pattern matching with path filtering +type globRenderer struct { + baseRenderer +} + +// Render displays the glob pattern with optional path parameter +func (gr globRenderer) Render(v *toolCallCmp) string { + var params tools.GlobParams + if err := gr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return gr.renderError(v, "Invalid glob parameters") + } + + args := newParamBuilder(). + addMain(params.Pattern). + addKeyValue("path", params.Path). + build() + + return gr.renderWithParams(v, "Glob", args, func() string { + return renderPlainContent(v, v.result.Content) + }) +} + +// ----------------------------------------------------------------------------- +// Grep renderer +// ----------------------------------------------------------------------------- + +// grepRenderer handles content searching with pattern matching options +type grepRenderer struct { + baseRenderer +} + +// Render displays the search pattern with path, include, and literal text options +func (gr grepRenderer) Render(v *toolCallCmp) string { + var params tools.GrepParams + if err := gr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return gr.renderError(v, "Invalid grep parameters") + } + + args := newParamBuilder(). + addMain(params.Pattern). + addKeyValue("path", params.Path). + addKeyValue("include", params.Include). + addFlag("literal", params.LiteralText). + build() + + return gr.renderWithParams(v, "Grep", args, func() string { + return renderPlainContent(v, v.result.Content) + }) +} + +// ----------------------------------------------------------------------------- +// LS renderer +// ----------------------------------------------------------------------------- + +// lsRenderer handles directory listing with default path handling +type lsRenderer struct { + baseRenderer +} + +// Render displays the directory path, defaulting to current directory +func (lr lsRenderer) Render(v *toolCallCmp) string { + var params tools.LSParams + if err := lr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return lr.renderError(v, "Invalid ls parameters") + } + + path := params.Path + if path == "" { + path = "." + } + path = fileutil.PrettyPath(path) + + args := newParamBuilder().addMain(path).build() + + return lr.renderWithParams(v, "List", args, func() string { + return renderPlainContent(v, v.result.Content) + }) +} + +// ----------------------------------------------------------------------------- +// Sourcegraph renderer +// ----------------------------------------------------------------------------- + +// sourcegraphRenderer handles code search with count and context options +type sourcegraphRenderer struct { + baseRenderer +} + +// Render displays the search query with optional count and context window parameters +func (sr sourcegraphRenderer) Render(v *toolCallCmp) string { + var params tools.SourcegraphParams + if err := sr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return sr.renderError(v, "Invalid sourcegraph parameters") + } + + args := newParamBuilder(). + addMain(params.Query). + addKeyValue("count", formatNonZero(params.Count)). + addKeyValue("context", formatNonZero(params.ContextWindow)). + build() + + return sr.renderWithParams(v, "Sourcegraph", args, func() string { + return renderPlainContent(v, v.result.Content) + }) +} + +// ----------------------------------------------------------------------------- +// Patch renderer +// ----------------------------------------------------------------------------- + +// patchRenderer handles multi-file patches with change summaries +type patchRenderer struct { + baseRenderer +} + +// Render displays patch summary with file count and change statistics +func (pr patchRenderer) Render(v *toolCallCmp) string { + var params tools.PatchParams + if err := pr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return pr.renderError(v, "Invalid patch parameters") + } + + args := newParamBuilder().addMain("multiple files").build() + + return pr.renderWithParams(v, "Patch", args, func() string { + var meta tools.PatchResponseMetadata + if err := pr.unmarshalParams(v.result.Metadata, &meta); err != nil { + return renderPlainContent(v, v.result.Content) + } + + summary := fmt.Sprintf("Changed %d files (%d+ %d-)", + len(meta.FilesChanged), meta.Additions, meta.Removals) + filesList := strings.Join(meta.FilesChanged, "\n") + + return renderPlainContent(v, summary+"\n\n"+filesList) + }) +} + +// ----------------------------------------------------------------------------- +// Diagnostics renderer +// ----------------------------------------------------------------------------- + +// diagnosticsRenderer handles project-wide diagnostic information +type diagnosticsRenderer struct { + baseRenderer +} + +// Render displays project diagnostics with plain content formatting +func (dr diagnosticsRenderer) Render(v *toolCallCmp) string { + args := newParamBuilder().addMain("project").build() + + return dr.renderWithParams(v, "Diagnostics", args, func() string { + return renderPlainContent(v, v.result.Content) + }) +} + +// ----------------------------------------------------------------------------- +// Task renderer +// ----------------------------------------------------------------------------- + +// agentRenderer handles project-wide diagnostic information +type agentRenderer struct { + baseRenderer +} + +// Render displays agent task parameters and result content +func (tr agentRenderer) Render(v *toolCallCmp) string { + var params agent.AgentParams + if err := tr.unmarshalParams(v.call.Input, ¶ms); err != nil { + return tr.renderError(v, "Invalid task parameters") + } + prompt := params.Prompt + prompt = strings.ReplaceAll(prompt, "\n", " ") + args := newParamBuilder().addMain(prompt).build() + + header := tr.makeHeader(v, "Task", v.textWidth(), args...) + t := tree.Root(header) + + for _, call := range v.nestedToolCalls { + t.Child(call.View()) + } + + parts := []string{ + t.Enumerator(tree.RoundedEnumerator).String(), + } + if v.result.ToolCallID == "" { + v.spinning = true + parts = append(parts, v.anim.View().String()) + } else { + v.spinning = false + } + + header = lipgloss.JoinVertical( + lipgloss.Left, + parts..., + ) + + if v.result.ToolCallID == "" { + return header + } + + body := renderPlainContent(v, v.result.Content) + return joinHeaderBody(header, body) +} + +// renderParamList renders params, params[0] (params[1]=params[2] ....) +func renderParamList(paramsWidth int, params ...string) string { + if len(params) == 0 { + return "" + } + mainParam := params[0] + if len(mainParam) > paramsWidth { + mainParam = mainParam[:paramsWidth-3] + "..." + } + + if len(params) == 1 { + return mainParam + } + otherParams := params[1:] + // create pairs of key/value + // if odd number of params, the last one is a key without value + if len(otherParams)%2 != 0 { + otherParams = append(otherParams, "") + } + parts := make([]string, 0, len(otherParams)/2) + for i := 0; i < len(otherParams); i += 2 { + key := otherParams[i] + value := otherParams[i+1] + if value == "" { + continue + } + parts = append(parts, fmt.Sprintf("%s=%s", key, value)) + } + + partsRendered := strings.Join(parts, ", ") + remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()" + if remainingWidth < 30 { + // No space for the params, just show the main + return mainParam + } + + if len(parts) > 0 { + mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", ")) + } + + return ansi.Truncate(mainParam, paramsWidth, "...") +} + +// earlyState returns immediately‑rendered error/cancelled/ongoing states. +func earlyState(header string, v *toolCallCmp) (string, bool) { + t := styles.CurrentTheme() + message := "" + switch { + case v.result.IsError: + message = v.renderToolError() + case v.cancelled: + message = "Cancelled" + case v.result.ToolCallID == "": + message = "Waiting for tool to start..." + default: + return "", false + } + + message = t.S().Base.PaddingLeft(2).Render(message) + return lipgloss.JoinVertical(lipgloss.Left, header, message), true +} + +func joinHeaderBody(header, body string) string { + t := styles.CurrentTheme() + body = t.S().Base.PaddingLeft(2).Render(body) + return lipgloss.JoinVertical(lipgloss.Left, header, body, "") +} + +func renderPlainContent(v *toolCallCmp, content string) string { + t := styles.CurrentTheme() + content = strings.TrimSpace(content) + lines := strings.Split(content, "\n") + + width := v.textWidth() - 2 // -2 for left padding + var out []string + for i, ln := range lines { + if i >= responseContextHeight { + break + } + ln = " " + ln // left padding + if len(ln) > width { + ln = v.fit(ln, width) + } + out = append(out, t.S().Muted. + Width(width). + Background(t.BgSubtle). + Render(ln)) + } + + if len(lines) > responseContextHeight { + out = append(out, t.S().Muted. + Background(t.BgSubtle). + Width(width). + Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight))) + } + return strings.Join(out, "\n") +} + +func renderCodeContent(v *toolCallCmp, path, content string, offset int) string { + t := styles.CurrentTheme() + truncated := truncateHeight(content, responseContextHeight) + + highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgSubtle) + lines := strings.Split(highlighted, "\n") + + if len(strings.Split(content, "\n")) > responseContextHeight { + lines = append(lines, t.S().Muted. + Background(t.BgSubtle). + Width(v.textWidth()-2). + Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight))) + } + + for i, ln := range lines { + num := t.S().Muted. + Background(t.BgSubtle). + PaddingLeft(4). + PaddingRight(2). + Render(fmt.Sprintf("%d", i+1+offset)) + w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding + lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, + num, + t.S().Base. + Width(w). + Background(t.BgSubtle). + Render(v.fit(ln, w))) + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (v *toolCallCmp) renderToolError() string { + t := styles.CurrentTheme() + err := strings.ReplaceAll(v.result.Content, "\n", " ") + err = fmt.Sprintf("Error: %s", err) + return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth())) +} + +func truncateHeight(s string, h int) string { + lines := strings.Split(s, "\n") + if len(lines) > h { + return strings.Join(lines[:h], "\n") + } + return s +} + +func prettifyToolName(name string) string { + switch name { + case agent.AgentToolName: + return "Task" + case tools.BashToolName: + return "Bash" + case tools.EditToolName: + return "Edit" + case tools.FetchToolName: + return "Fetch" + case tools.GlobToolName: + return "Glob" + case tools.GrepToolName: + return "Grep" + case tools.LSToolName: + return "List" + case tools.SourcegraphToolName: + return "Sourcegraph" + case tools.ViewToolName: + return "View" + case tools.WriteToolName: + return "Write" + case tools.PatchToolName: + return "Patch" + default: + return name + } +} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go new file mode 100644 index 0000000000000000000000000000000000000000..cbc5548904217b3ea14595eaf676229dd0b54bc1 --- /dev/null +++ b/internal/tui/components/chat/messages/tool.go @@ -0,0 +1,295 @@ +package messages + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/v2/spinner" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/tui/components/anim" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// ToolCallCmp defines the interface for tool call components in the chat interface. +// It manages the display of tool execution including pending states, results, and errors. +type ToolCallCmp interface { + util.Model // Basic Bubble Tea model interface + layout.Sizeable // Width/height management + layout.Focusable // Focus state management + GetToolCall() message.ToolCall // Access to tool call data + GetToolResult() message.ToolResult // Access to tool result data + SetToolResult(message.ToolResult) // Update tool result + SetToolCall(message.ToolCall) // Update tool call + SetCancelled() // Mark as cancelled + ParentMessageId() string // Get parent message ID + Spinning() bool // Animation state for pending tools + GetNestedToolCalls() []ToolCallCmp // Get nested tool calls + SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls + SetIsNested(bool) // Set whether this tool call is nested +} + +// toolCallCmp implements the ToolCallCmp interface for displaying tool calls. +// It handles rendering of tool execution states including pending, completed, and error states. +type toolCallCmp struct { + width int // Component width for text wrapping + focused bool // Focus state for border styling + isNested bool // Whether this tool call is nested within another + + // Tool call data and state + parentMessageID string // ID of the message that initiated this tool call + call message.ToolCall // The tool call being executed + result message.ToolResult // The result of the tool execution + cancelled bool // Whether the tool call was cancelled + + // Animation state for pending tool calls + spinning bool // Whether to show loading animation + anim util.Model // Animation component for pending states + + nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display +} + +// ToolCallOption provides functional options for configuring tool call components +type ToolCallOption func(*toolCallCmp) + +// WithToolCallCancelled marks the tool call as cancelled +func WithToolCallCancelled() ToolCallOption { + return func(m *toolCallCmp) { + m.cancelled = true + } +} + +// WithToolCallResult sets the initial tool result +func WithToolCallResult(result message.ToolResult) ToolCallOption { + return func(m *toolCallCmp) { + m.result = result + } +} + +func WithToolCallNested(isNested bool) ToolCallOption { + return func(m *toolCallCmp) { + m.isNested = isNested + } +} + +func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption { + return func(m *toolCallCmp) { + m.nestedToolCalls = calls + } +} + +// NewToolCallCmp creates a new tool call component with the given parent message ID, +// tool call, and optional configuration +func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp { + m := &toolCallCmp{ + call: tc, + parentMessageID: parentMessageId, + } + for _, opt := range opts { + opt(m) + } + m.anim = anim.New(15, "Working") + if m.isNested { + m.anim = anim.New(10, "") + } + return m +} + +// Init initializes the tool call component and starts animations if needed. +// Returns a command to start the animation for pending tool calls. +func (m *toolCallCmp) Init() tea.Cmd { + m.spinning = m.shouldSpin() + if m.spinning { + return m.anim.Init() + } + return nil +} + +// Update handles incoming messages and updates the component state. +// Manages animation updates for pending tool calls. +func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg: + var cmds []tea.Cmd + for i, nested := range m.nestedToolCalls { + if nested.Spinning() { + u, cmd := nested.Update(msg) + m.nestedToolCalls[i] = u.(ToolCallCmp) + cmds = append(cmds, cmd) + } + } + if m.spinning { + u, cmd := m.anim.Update(msg) + m.anim = u.(util.Model) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + return m, nil +} + +// View renders the tool call component based on its current state. +// Shows either a pending animation or the tool-specific rendered result. +func (m *toolCallCmp) View() tea.View { + box := m.style() + + if !m.call.Finished && !m.cancelled { + if m.isNested { + return tea.NewView(box.Render(m.renderPending())) + } + return tea.NewView(box.Render(m.renderPending())) + } + + r := registry.lookup(m.call.Name) + + if m.isNested { + return tea.NewView(box.Render(r.Render(m))) + } + return tea.NewView(box.Render(r.Render(m))) +} + +// State management methods + +// SetCancelled marks the tool call as cancelled +func (m *toolCallCmp) SetCancelled() { + m.cancelled = true +} + +// SetToolCall updates the tool call data and stops spinning if finished +func (m *toolCallCmp) SetToolCall(call message.ToolCall) { + m.call = call + if m.call.Finished { + m.spinning = false + } +} + +// ParentMessageId returns the ID of the message that initiated this tool call +func (m *toolCallCmp) ParentMessageId() string { + return m.parentMessageID +} + +// SetToolResult updates the tool result and stops the spinning animation +func (m *toolCallCmp) SetToolResult(result message.ToolResult) { + m.result = result + m.spinning = false +} + +// GetToolCall returns the current tool call data +func (m *toolCallCmp) GetToolCall() message.ToolCall { + return m.call +} + +// GetToolResult returns the current tool result data +func (m *toolCallCmp) GetToolResult() message.ToolResult { + return m.result +} + +// GetNestedToolCalls returns the nested tool calls +func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp { + return m.nestedToolCalls +} + +// SetNestedToolCalls sets the nested tool calls +func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) { + m.nestedToolCalls = calls + for _, nested := range m.nestedToolCalls { + nested.SetSize(m.width, 0) + } +} + +// SetIsNested sets whether this tool call is nested within another +func (m *toolCallCmp) SetIsNested(isNested bool) { + m.isNested = isNested +} + +// Rendering methods + +// renderPending displays the tool name with a loading animation for pending tool calls +func (m *toolCallCmp) renderPending() string { + t := styles.CurrentTheme() + icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending) + tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name)) + return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View()) +} + +// style returns the lipgloss style for the tool call component. +// Applies muted colors and focus-dependent border styles. +func (m *toolCallCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + + if m.isNested { + return t.S().Muted + } + return t.S().Muted.PaddingLeft(4) +} + +// textWidth calculates the available width for text content, +// accounting for borders and padding +func (m *toolCallCmp) textWidth() int { + return m.width - 5 // take into account the border and PaddingLeft +} + +// fit truncates content to fit within the specified width with ellipsis +func (m *toolCallCmp) fit(content string, width int) string { + t := styles.CurrentTheme() + lineStyle := t.S().Muted.Background(t.BgSubtle) + dots := lineStyle.Render("...") + return ansi.Truncate(content, width, dots) +} + +// Focus management methods + +// Blur removes focus from the tool call component +func (m *toolCallCmp) Blur() tea.Cmd { + m.focused = false + return nil +} + +// Focus sets focus on the tool call component +func (m *toolCallCmp) Focus() tea.Cmd { + m.focused = true + return nil +} + +// IsFocused returns whether the tool call component is currently focused +func (m *toolCallCmp) IsFocused() bool { + return m.focused +} + +// Size management methods + +// GetSize returns the current dimensions of the tool call component +func (m *toolCallCmp) GetSize() (int, int) { + return m.width, 0 +} + +// SetSize updates the width of the tool call component for text wrapping +func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd { + m.width = width + for _, nested := range m.nestedToolCalls { + nested.SetSize(width, height) + } + return nil +} + +// shouldSpin determines whether the tool call should show a loading animation. +// Returns true if the tool call is not finished or if the result doesn't match the call ID. +func (m *toolCallCmp) shouldSpin() bool { + return !m.call.Finished +} + +// Spinning returns whether the tool call is currently showing a loading animation +func (m *toolCallCmp) Spinning() bool { + if m.spinning { + return true + } + for _, nested := range m.nestedToolCalls { + if nested.Spinning() { + return true + } + } + return m.spinning +} diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go deleted file mode 100644 index a66249b368cd28274333395cb79a5b127b3fb8f9..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/sidebar.go +++ /dev/null @@ -1,378 +0,0 @@ -package chat - -import ( - "context" - "fmt" - "sort" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/diff" - "github.com/opencode-ai/opencode/internal/history" - "github.com/opencode-ai/opencode/internal/pubsub" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" -) - -type sidebarCmp struct { - width, height int - session session.Session - history history.Service - modFiles map[string]struct { - additions int - removals int - } -} - -func (m *sidebarCmp) Init() tea.Cmd { - if m.history != nil { - ctx := context.Background() - // Subscribe to file events - filesCh := m.history.Subscribe(ctx) - - // Initialize the modified files map - m.modFiles = make(map[string]struct { - additions int - removals int - }) - - // Load initial files and calculate diffs - m.loadModifiedFiles(ctx) - - // Return a command that will send file events to the Update method - return func() tea.Msg { - return <-filesCh - } - } - return nil -} - -func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case SessionSelectedMsg: - if msg.ID != m.session.ID { - m.session = msg - ctx := context.Background() - m.loadModifiedFiles(ctx) - } - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent { - if m.session.ID == msg.Payload.ID { - m.session = msg.Payload - } - } - case pubsub.Event[history.File]: - if msg.Payload.SessionID == m.session.ID { - // Process the individual file change instead of reloading all files - ctx := context.Background() - m.processFileChanges(ctx, msg.Payload) - - // Return a command to continue receiving events - return m, func() tea.Msg { - ctx := context.Background() - filesCh := m.history.Subscribe(ctx) - return <-filesCh - } - } - } - return m, nil -} - -func (m *sidebarCmp) View() string { - baseStyle := styles.BaseStyle() - - return baseStyle. - Width(m.width). - PaddingLeft(4). - PaddingRight(2). - Height(m.height - 1). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - header(m.width), - " ", - m.sessionSection(), - " ", - lspsConfigured(m.width), - " ", - m.modifiedFiles(), - ), - ) -} - -func (m *sidebarCmp) sessionSection() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - sessionKey := baseStyle. - Foreground(t.Primary()). - Bold(true). - Render("Session") - - sessionValue := baseStyle. - Foreground(t.Text()). - Width(m.width - lipgloss.Width(sessionKey)). - Render(fmt.Sprintf(": %s", m.session.Title)) - - return lipgloss.JoinHorizontal( - lipgloss.Left, - sessionKey, - sessionValue, - ) -} - -func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - stats := "" - if additions > 0 && removals > 0 { - additionsStr := baseStyle. - Foreground(t.Success()). - PaddingLeft(1). - Render(fmt.Sprintf("+%d", additions)) - - removalsStr := baseStyle. - Foreground(t.Error()). - PaddingLeft(1). - Render(fmt.Sprintf("-%d", removals)) - - content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr) - stats = baseStyle.Width(lipgloss.Width(content)).Render(content) - } else if additions > 0 { - additionsStr := fmt.Sprintf(" %s", baseStyle. - PaddingLeft(1). - Foreground(t.Success()). - Render(fmt.Sprintf("+%d", additions))) - stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr) - } else if removals > 0 { - removalsStr := fmt.Sprintf(" %s", baseStyle. - PaddingLeft(1). - Foreground(t.Error()). - Render(fmt.Sprintf("-%d", removals))) - stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr) - } - - filePathStr := baseStyle.Render(filePath) - - return baseStyle. - Width(m.width). - Render( - lipgloss.JoinHorizontal( - lipgloss.Left, - filePathStr, - stats, - ), - ) -} - -func (m *sidebarCmp) modifiedFiles() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - modifiedFiles := baseStyle. - Width(m.width). - Foreground(t.Primary()). - Bold(true). - Render("Modified Files:") - - // If no modified files, show a placeholder message - if m.modFiles == nil || len(m.modFiles) == 0 { - message := "No modified files" - remainingWidth := m.width - lipgloss.Width(message) - if remainingWidth > 0 { - message += strings.Repeat(" ", remainingWidth) - } - return baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - modifiedFiles, - baseStyle.Foreground(t.TextMuted()).Render(message), - ), - ) - } - - // Sort file paths alphabetically for consistent ordering - var paths []string - for path := range m.modFiles { - paths = append(paths, path) - } - sort.Strings(paths) - - // Create views for each file in sorted order - var fileViews []string - for _, path := range paths { - stats := m.modFiles[path] - fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals)) - } - - return baseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Top, - modifiedFiles, - lipgloss.JoinVertical( - lipgloss.Left, - fileViews..., - ), - ), - ) -} - -func (m *sidebarCmp) SetSize(width, height int) tea.Cmd { - m.width = width - m.height = height - return nil -} - -func (m *sidebarCmp) GetSize() (int, int) { - return m.width, m.height -} - -func NewSidebarCmp(session session.Session, history history.Service) tea.Model { - return &sidebarCmp{ - session: session, - history: history, - } -} - -func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) { - if m.history == nil || m.session.ID == "" { - return - } - - // Get all latest files for this session - latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID) - if err != nil { - return - } - - // Get all files for this session (to find initial versions) - allFiles, err := m.history.ListBySession(ctx, m.session.ID) - if err != nil { - return - } - - // Clear the existing map to rebuild it - m.modFiles = make(map[string]struct { - additions int - removals int - }) - - // Process each latest file - for _, file := range latestFiles { - // Skip if this is the initial version (no changes to show) - if file.Version == history.InitialVersion { - continue - } - - // Find the initial version for this specific file - var initialVersion history.File - for _, v := range allFiles { - if v.Path == file.Path && v.Version == history.InitialVersion { - initialVersion = v - break - } - } - - // Skip if we can't find the initial version - if initialVersion.ID == "" { - continue - } - if initialVersion.Content == file.Content { - continue - } - - // Calculate diff between initial and latest version - _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path) - - // Only add to modified files if there are changes - if additions > 0 || removals > 0 { - // Remove working directory prefix from file path - displayPath := file.Path - workingDir := config.WorkingDirectory() - displayPath = strings.TrimPrefix(displayPath, workingDir) - displayPath = strings.TrimPrefix(displayPath, "/") - - m.modFiles[displayPath] = struct { - additions int - removals int - }{ - additions: additions, - removals: removals, - } - } - } -} - -func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) { - // Skip if this is the initial version (no changes to show) - if file.Version == history.InitialVersion { - return - } - - // Find the initial version for this file - initialVersion, err := m.findInitialVersion(ctx, file.Path) - if err != nil || initialVersion.ID == "" { - return - } - - // Skip if content hasn't changed - if initialVersion.Content == file.Content { - // If this file was previously modified but now matches the initial version, - // remove it from the modified files list - displayPath := getDisplayPath(file.Path) - delete(m.modFiles, displayPath) - return - } - - // Calculate diff between initial and latest version - _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path) - - // Only add to modified files if there are changes - if additions > 0 || removals > 0 { - displayPath := getDisplayPath(file.Path) - m.modFiles[displayPath] = struct { - additions int - removals int - }{ - additions: additions, - removals: removals, - } - } else { - // If no changes, remove from modified files - displayPath := getDisplayPath(file.Path) - delete(m.modFiles, displayPath) - } -} - -// Helper function to find the initial version of a file -func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) { - // Get all versions of this file for the session - fileVersions, err := m.history.ListBySession(ctx, m.session.ID) - if err != nil { - return history.File{}, err - } - - // Find the initial version - for _, v := range fileVersions { - if v.Path == path && v.Version == history.InitialVersion { - return v, nil - } - } - - return history.File{}, fmt.Errorf("initial version not found") -} - -// Helper function to get the display path for a file -func getDisplayPath(path string) string { - workingDir := config.WorkingDirectory() - displayPath := strings.TrimPrefix(path, workingDir) - return strings.TrimPrefix(displayPath, "/") -} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go new file mode 100644 index 0000000000000000000000000000000000000000..425b1468a50a5590ca23f100b61f79e8e3802867 --- /dev/null +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -0,0 +1,207 @@ +package sidebar + +import ( + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/logo" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/crush/internal/version" + "github.com/charmbracelet/lipgloss/v2" +) + +const ( + logoBreakpoint = 65 +) + +type Sidebar interface { + util.Model + layout.Sizeable +} + +type sidebarCmp struct { + width, height int + session session.Session + logo string + cwd string +} + +func NewSidebarCmp() Sidebar { + return &sidebarCmp{} +} + +func (m *sidebarCmp) Init() tea.Cmd { + m.logo = m.logoBlock(false) + m.cwd = cwd() + return nil +} + +func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case chat.SessionSelectedMsg: + if msg.ID != m.session.ID { + m.session = msg + } + case chat.SessionClearedMsg: + m.session = session.Session{} + case pubsub.Event[session.Session]: + if msg.Type == pubsub.UpdatedEvent { + if m.session.ID == msg.Payload.ID { + m.session = msg.Payload + } + } + } + return m, nil +} + +func (m *sidebarCmp) View() tea.View { + t := styles.CurrentTheme() + parts := []string{ + m.logo, + } + + if m.session.ID != "" { + parts = append(parts, t.S().Muted.Render(m.session.Title), "") + } + + parts = append(parts, + m.cwd, + "", + m.lspBlock(), + "", + m.mcpBlock(), + ) + + return tea.NewView( + lipgloss.JoinVertical(lipgloss.Left, parts...), + ) +} + +func (m *sidebarCmp) SetSize(width, height int) tea.Cmd { + if width < logoBreakpoint && m.width >= logoBreakpoint { + m.logo = m.logoBlock(true) + } else if width >= logoBreakpoint && m.width < logoBreakpoint { + m.logo = m.logoBlock(false) + } + + m.width = width + m.height = height + return nil +} + +func (m *sidebarCmp) GetSize() (int, int) { + return m.width, m.height +} + +func (m *sidebarCmp) logoBlock(compact bool) string { + t := styles.CurrentTheme() + return logo.Render(version.Version, compact, logo.Opts{ + FieldColor: t.Primary, + TitleColorA: t.Secondary, + TitleColorB: t.Primary, + CharmColor: t.Secondary, + VersionColor: t.Primary, + }) +} + +func (m *sidebarCmp) lspBlock() string { + maxWidth := min(m.width, 58) + t := styles.CurrentTheme() + + section := t.S().Muted.Render( + core.Section("LSPs", maxWidth), + ) + + lspList := []string{section, ""} + + lsp := config.Get().LSP + if len(lsp) == 0 { + return lipgloss.JoinVertical( + lipgloss.Left, + section, + "", + t.S().Base.Foreground(t.Border).Render("None"), + ) + } + + for n, l := range lsp { + iconColor := t.Success + if l.Disabled { + iconColor = t.FgMuted + } + lspList = append(lspList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: n, + Description: l.Command, + }, + m.width, + ), + ) + } + + return lipgloss.JoinVertical( + lipgloss.Left, + lspList..., + ) +} + +func (m *sidebarCmp) mcpBlock() string { + maxWidth := min(m.width, 58) + t := styles.CurrentTheme() + + section := t.S().Muted.Render( + core.Section("MCPs", maxWidth), + ) + + mcpList := []string{section, ""} + + mcp := config.Get().MCPServers + if len(mcp) == 0 { + return lipgloss.JoinVertical( + lipgloss.Left, + section, + "", + t.S().Base.Foreground(t.Border).Render("None"), + ) + } + + for n, l := range mcp { + iconColor := t.Success + mcpList = append(mcpList, + core.Status( + core.StatusOpts{ + IconColor: iconColor, + Title: n, + Description: l.Command, + }, + m.width, + ), + ) + } + + return lipgloss.JoinVertical( + lipgloss.Left, + mcpList..., + ) +} + +func cwd() string { + cwd := config.WorkingDirectory() + t := styles.CurrentTheme() + // replace home directory with ~ + homeDir, err := os.UserHomeDir() + if err == nil { + cwd = strings.ReplaceAll(cwd, homeDir, "~") + } + return t.S().Muted.Render(cwd) +} diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go new file mode 100644 index 0000000000000000000000000000000000000000..625c49caba3ca070d07902845e82478d8064274e --- /dev/null +++ b/internal/tui/components/completions/completions.go @@ -0,0 +1,195 @@ +package completions + +import ( + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +type Completion struct { + Title string // The title of the completion item + Value any // The value of the completion item +} + +type OpenCompletionsMsg struct { + Completions []Completion + X int // X position for the completions popup + Y int // Y position for the completions popup +} + +type FilterCompletionsMsg struct { + Query string // The query to filter completions +} + +type CompletionsClosedMsg struct{} + +type CloseCompletionsMsg struct{} + +type SelectCompletionMsg struct { + Value any // The value of the selected completion item +} + +type Completions interface { + util.Model + Open() bool + Query() string // Returns the current filter query + KeyMap() KeyMap + Position() (int, int) // Returns the X and Y position of the completions popup +} + +type completionsCmp struct { + width int + height int // Height of the completions component` + x int // X position for the completions popup\ + y int // Y position for the completions popup + open bool // Indicates if the completions are open + keyMap KeyMap + + list list.ListModel + query string // The current filter query +} + +func New() Completions { + completionsKeyMap := DefaultKeyMap() + keyMap := list.DefaultKeyMap() + keyMap.Up.SetEnabled(false) + keyMap.Down.SetEnabled(false) + keyMap.NDown.SetEnabled(false) + keyMap.NUp.SetEnabled(false) + keyMap.HalfPageDown.SetEnabled(false) + keyMap.HalfPageUp.SetEnabled(false) + keyMap.Home.SetEnabled(false) + keyMap.End.SetEnabled(false) + keyMap.UpOneItem = completionsKeyMap.Up + keyMap.DownOneItem = completionsKeyMap.Down + + l := list.New( + list.WithReverse(true), + list.WithKeyMap(keyMap), + list.WithHideFilterInput(true), + ) + return &completionsCmp{ + width: 30, + height: 10, + list: l, + query: "", + keyMap: completionsKeyMap, + } +} + +// Init implements Completions. +func (c *completionsCmp) Init() tea.Cmd { + return tea.Sequence( + c.list.Init(), + c.list.SetSize(c.width, c.height), + ) +} + +// Update implements Completions. +func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, c.keyMap.Up): + u, cmd := c.list.Update(msg) + c.list = u.(list.ListModel) + return c, cmd + + case key.Matches(msg, c.keyMap.Down): + d, cmd := c.list.Update(msg) + c.list = d.(list.ListModel) + return c, cmd + case key.Matches(msg, c.keyMap.Select): + selectedItemInx := c.list.SelectedIndex() + if selectedItemInx == list.NoSelection { + return c, nil // No item selected, do nothing + } + items := c.list.Items() + selectedItem := items[selectedItemInx].(CompletionItem).Value() + c.open = false // Close completions after selection + return c, util.CmdHandler(SelectCompletionMsg{ + Value: selectedItem, + }) + case key.Matches(msg, c.keyMap.Cancel): + if c.open { + c.open = false + return c, util.CmdHandler(CompletionsClosedMsg{}) + } + } + case CloseCompletionsMsg: + c.open = false + c.query = "" + return c, tea.Batch( + c.list.SetItems([]util.Model{}), + util.CmdHandler(CompletionsClosedMsg{}), + ) + case OpenCompletionsMsg: + c.open = true + c.query = "" + c.x = msg.X + c.y = msg.Y + items := []util.Model{} + t := styles.CurrentTheme() + for _, completion := range msg.Completions { + item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle)) + items = append(items, item) + } + c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height + cmds := []tea.Cmd{ + c.list.SetSize(c.width, c.height), + c.list.SetItems(items), + } + return c, tea.Batch(cmds...) + case FilterCompletionsMsg: + c.query = msg.Query + if !c.open { + return c, nil // If completions are not open, do nothing + } + cmd := c.list.Filter(msg.Query) + c.height = max(min(10, len(c.list.Items())), 1) + return c, tea.Batch( + cmd, + c.list.SetSize(c.width, c.height), + ) + } + return c, nil +} + +// View implements Completions. +func (c *completionsCmp) View() tea.View { + if len(c.list.Items()) == 0 { + return tea.NewView(c.style().Render("No completions found")) + } + + view := tea.NewView( + c.style().Render(c.list.View().String()), + ) + return view +} + +func (c *completionsCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(c.width). + Height(c.height). + Background(t.BgSubtle) +} + +func (c *completionsCmp) Open() bool { + return c.open +} + +func (c *completionsCmp) Query() string { + return c.query +} + +func (c *completionsCmp) KeyMap() KeyMap { + return c.keyMap +} + +func (c *completionsCmp) Position() (int, int) { + return c.x, c.y - c.height +} diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go new file mode 100644 index 0000000000000000000000000000000000000000..ceab34a5ccbb58ce318b57764a51e4fcc407d2ce --- /dev/null +++ b/internal/tui/components/completions/item.go @@ -0,0 +1,281 @@ +package completions + +import ( + "image/color" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" +) + +type CompletionItem interface { + util.Model + layout.Focusable + layout.Sizeable + list.HasMatchIndexes + list.HasFilterValue + Value() any +} + +type completionItemCmp struct { + width int + text string + value any + focus bool + matchIndexes []int + bgColor color.Color + shortcut string +} + +type CompletionOption func(*completionItemCmp) + +func WithBackgroundColor(c color.Color) CompletionOption { + return func(cmp *completionItemCmp) { + cmp.bgColor = c + } +} + +func WithMatchIndexes(indexes ...int) CompletionOption { + return func(cmp *completionItemCmp) { + cmp.matchIndexes = indexes + } +} + +func WithShortcut(shortcut string) CompletionOption { + return func(cmp *completionItemCmp) { + cmp.shortcut = shortcut + } +} + +func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem { + c := &completionItemCmp{ + text: text, + value: value, + } + + for _, opt := range opts { + opt(c) + } + return c +} + +// Init implements CommandItem. +func (c *completionItemCmp) Init() tea.Cmd { + return nil +} + +// Update implements CommandItem. +func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) { + return c, nil +} + +// View implements CommandItem. +func (c *completionItemCmp) View() tea.View { + t := styles.CurrentTheme() + + itemStyle := t.S().Base.Padding(0, 1).Width(c.width) + innerWidth := c.width - 2 // Account for padding + + if c.shortcut != "" { + innerWidth -= lipgloss.Width(c.shortcut) + } + + titleStyle := t.S().Text.Width(innerWidth) + titleMatchStyle := t.S().Text.Underline(true) + if c.bgColor != nil { + titleStyle = titleStyle.Background(c.bgColor) + titleMatchStyle = titleMatchStyle.Background(c.bgColor) + } + + if c.focus { + titleStyle = t.S().TextSelected.Width(innerWidth) + titleMatchStyle = t.S().TextSelected.Underline(true) + itemStyle = itemStyle.Background(t.Primary) + } + + var truncatedTitle string + + if len(c.matchIndexes) > 0 && len(c.text) > innerWidth { + // Smart truncation: ensure the last matching part is visible + truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes) + } else { + // No matches, use regular truncation + truncatedTitle = ansi.Truncate(c.text, innerWidth, "…") + } + + text := titleStyle.Render(truncatedTitle) + if len(c.matchIndexes) > 0 { + var ranges []lipgloss.Range + for _, rng := range matchedRanges(c.matchIndexes) { + // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. + // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. + // so we need to adjust it here: + start, stop := bytePosToVisibleCharPos(truncatedTitle, rng) + ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle)) + } + text = lipgloss.StyleRanges(text, ranges...) + } + parts := []string{text} + if c.shortcut != "" { + // Add the shortcut at the end + shortcutStyle := t.S().Muted + if c.focus { + shortcutStyle = t.S().TextSelected + } + parts = append(parts, shortcutStyle.Render(c.shortcut)) + } + item := itemStyle.Render( + lipgloss.JoinHorizontal( + lipgloss.Left, + parts..., + ), + ) + return tea.NewView(item) +} + +// Blur implements CommandItem. +func (c *completionItemCmp) Blur() tea.Cmd { + c.focus = false + return nil +} + +// Focus implements CommandItem. +func (c *completionItemCmp) Focus() tea.Cmd { + c.focus = true + return nil +} + +// GetSize implements CommandItem. +func (c *completionItemCmp) GetSize() (int, int) { + return c.width, 1 +} + +// IsFocused implements CommandItem. +func (c *completionItemCmp) IsFocused() bool { + return c.focus +} + +// SetSize implements CommandItem. +func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd { + c.width = width + return nil +} + +func (c *completionItemCmp) MatchIndexes(indexes []int) { + c.matchIndexes = indexes +} + +func (c *completionItemCmp) FilterValue() string { + return c.text +} + +func (c *completionItemCmp) Value() any { + return c.value +} + +// smartTruncate implements fzf-style truncation that ensures the last matching part is visible +func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string { + if width <= 0 { + return "" + } + + textLen := ansi.StringWidth(text) + if textLen <= width { + return text + } + + if len(matchIndexes) == 0 { + return ansi.Truncate(text, width, "…") + } + + // Find the last match position + lastMatchPos := matchIndexes[len(matchIndexes)-1] + + // Convert byte position to visual width position + lastMatchVisualPos := 0 + bytePos := 0 + gr := uniseg.NewGraphemes(text) + for bytePos < lastMatchPos && gr.Next() { + bytePos += len(gr.Str()) + lastMatchVisualPos += max(1, gr.Width()) + } + + // Calculate how much space we need for the ellipsis + ellipsisWidth := 1 // "…" character width + availableWidth := width - ellipsisWidth + + // If the last match is within the available width, truncate from the end + if lastMatchVisualPos < availableWidth { + return ansi.Truncate(text, width, "…") + } + + // Calculate the start position to ensure the last match is visible + // We want to show some context before the last match if possible + startVisualPos := max(0, lastMatchVisualPos-availableWidth+1) + + // Convert visual position back to byte position + startBytePos := 0 + currentVisualPos := 0 + gr = uniseg.NewGraphemes(text) + for currentVisualPos < startVisualPos && gr.Next() { + startBytePos += len(gr.Str()) + currentVisualPos += max(1, gr.Width()) + } + + // Extract the substring starting from startBytePos + truncatedText := text[startBytePos:] + + // Truncate to fit width with ellipsis + truncatedText = ansi.Truncate(truncatedText, availableWidth, "") + truncatedText = "…" + truncatedText + return truncatedText +} + +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..41bdeb384f79ea6d81ce45d12c5555ac32c04038 --- /dev/null +++ b/internal/tui/components/completions/keys.go @@ -0,0 +1,53 @@ +package completions + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + Down, + Up, + Select, + Cancel key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("down", "move down"), + ), + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("up", "move up"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Up, + k.Down, + } +} diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go new file mode 100644 index 0000000000000000000000000000000000000000..e586b0563278080eb85c7e0bbaa4dbee86e670e9 --- /dev/null +++ b/internal/tui/components/core/helpers.go @@ -0,0 +1,133 @@ +package core + +import ( + "image/color" + "strings" + + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func Section(text string, width int) string { + t := styles.CurrentTheme() + char := "─" + length := lipgloss.Width(text) + 1 + remainingWidth := width - length + lineStyle := t.S().Base.Foreground(t.Border) + if remainingWidth > 0 { + text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) + } + return text +} + +func Title(title string, width int) string { + t := styles.CurrentTheme() + char := "╱" + length := lipgloss.Width(title) + 1 + remainingWidth := width - length + titleStyle := t.S().Base.Foreground(t.Primary) + if remainingWidth > 0 { + lines := strings.Repeat(char, remainingWidth) + lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary) + title = titleStyle.Render(title) + " " + lines + } + return title +} + +type StatusOpts struct { + Icon string + IconColor color.Color + Title string + TitleColor color.Color + Description string + DescriptionColor color.Color +} + +func Status(ops StatusOpts, width int) string { + t := styles.CurrentTheme() + icon := "●" + iconColor := t.Success + if ops.Icon != "" { + icon = ops.Icon + } + if ops.IconColor != nil { + iconColor = ops.IconColor + } + title := ops.Title + titleColor := t.FgMuted + if ops.TitleColor != nil { + titleColor = ops.TitleColor + } + description := ops.Description + descriptionColor := t.FgSubtle + if ops.DescriptionColor != nil { + descriptionColor = ops.DescriptionColor + } + icon = t.S().Base.Foreground(iconColor).Render(icon) + title = t.S().Base.Foreground(titleColor).Render(title) + if description != "" { + description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2, "…") + } + description = t.S().Base.Foreground(descriptionColor).Render(description) + return strings.Join([]string{ + icon, + title, + description, + }, " ") +} + +type ButtonOpts struct { + Text string + UnderlineIndex int // Index of character to underline (0-based) + Selected bool // Whether this button is selected +} + +// SelectableButton creates a button with an underlined character and selection state +func SelectableButton(opts ButtonOpts) string { + t := styles.CurrentTheme() + + // Base style for the button + buttonStyle := t.S().Text + + // Apply selection styling + if opts.Selected { + buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary) + } else { + buttonStyle = buttonStyle.Background(t.BgSubtle) + } + + // Create the button text with underlined character + text := opts.Text + if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) { + before := text[:opts.UnderlineIndex] + underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1] + after := text[opts.UnderlineIndex+1:] + + message := buttonStyle.Render(before) + + buttonStyle.Underline(true).Render(underlined) + + buttonStyle.Render(after) + + return buttonStyle.Padding(0, 2).Render(message) + } + + // Fallback if no underline index specified + return buttonStyle.Padding(0, 2).Render(text) +} + +// SelectableButtons creates a horizontal row of selectable buttons +func SelectableButtons(buttons []ButtonOpts, spacing string) string { + if spacing == "" { + spacing = " " + } + + var parts []string + for i, button := range buttons { + parts = append(parts, SelectableButton(button)) + if i < len(buttons)-1 { + parts = append(parts, spacing) + } + } + + return lipgloss.JoinHorizontal(lipgloss.Left, parts...) +} diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..c5368354c06357a6a9b209f1896f336c97f4ea13 --- /dev/null +++ b/internal/tui/components/core/list/keys.go @@ -0,0 +1,73 @@ +package list + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + Down, + Up, + NDown, + NUp, + DownOneItem, + UpOneItem, + HalfPageDown, + HalfPageUp, + Home, + End key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Down: key.NewBinding( + key.WithKeys("down", "ctrl+j", "ctrl+n"), + ), + Up: key.NewBinding( + key.WithKeys("up", "ctrl+k", "ctrl+p"), + ), + NDown: key.NewBinding( + key.WithKeys("j"), + ), + NUp: key.NewBinding( + key.WithKeys("k"), + ), + UpOneItem: key.NewBinding( + key.WithKeys("shift+up", "shift+k"), + ), + DownOneItem: key.NewBinding( + key.WithKeys("shift+down", "shift+j"), + ), + HalfPageDown: key.NewBinding( + key.WithKeys("d"), + ), + HalfPageUp: key.NewBinding( + key.WithKeys("u"), + ), + Home: key.NewBinding( + key.WithKeys("g", "home"), + ), + End: key.NewBinding( + key.WithKeys("shift+g", "end"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Up, + k.Down, + } +} diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go new file mode 100644 index 0000000000000000000000000000000000000000..6cb2756aee506fdc5b421597675d47402a7f61c2 --- /dev/null +++ b/internal/tui/components/core/list/list.go @@ -0,0 +1,1360 @@ +package list + +import ( + "slices" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/spinner" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/anim" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" + "github.com/sahilm/fuzzy" +) + +// Constants for special index values and defaults +const ( + NoSelection = -1 // Indicates no item is currently selected + NotRendered = -1 // Indicates an item hasn't been rendered yet + NoFinalHeight = -1 // Indicates final height hasn't been calculated + DefaultGapSize = 0 // Default spacing between list items +) + +// ListModel defines the interface for a scrollable, selectable list component. +// It combines the basic Model interface with sizing capabilities and list-specific operations. +type ListModel interface { + util.Model + layout.Sizeable + layout.Focusable + SetItems([]util.Model) tea.Cmd // Replace all items in the list + AppendItem(util.Model) tea.Cmd // Add an item to the end of the list + PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list + DeleteItem(int) // Remove an item at the specified index + UpdateItem(int, util.Model) // Replace an item at the specified index + ResetView() // Clear rendering cache and reset scroll position + Items() []util.Model // Get all items in the list + SelectedIndex() int // Get the index of the currently selected item + SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it + Filter(string) tea.Cmd // Filter items based on a search term +} + +// HasAnim interface identifies items that support animation. +// Items implementing this interface will receive animation update messages. +type HasAnim interface { + util.Model + Spinning() bool // Returns true if the item is currently animating +} + +// HasFilterValue interface allows items to provide a filter value for searching. +type HasFilterValue interface { + FilterValue() string // Returns a string value used for filtering/searching +} + +// HasMatchIndexes interface allows items to set matched character indexes. +type HasMatchIndexes interface { + MatchIndexes([]int) // Sets the indexes of matched characters in the item's content +} + +// SectionHeader interface identifies items that are section headers. +// Section headers are rendered differently and are skipped during navigation. +type SectionHeader interface { + util.Model + IsSectionHeader() bool // Returns true if this item is a section header +} + +// renderedItem represents a cached rendered item with its position and content. +type renderedItem struct { + lines []string // The rendered lines of text for this item + start int // Starting line position in the overall rendered content + height int // Number of lines this item occupies +} + +// renderState manages the rendering cache and state for the list. +// It tracks which items have been rendered and their positions. +type renderState struct { + items map[int]renderedItem // Cache of rendered items by index + lines []string // All rendered lines concatenated + lastIndex int // Index of the last rendered item + finalHeight int // Total height when all items are rendered + needsRerender bool // Flag indicating if re-rendering is needed +} + +// newRenderState creates a new render state with default values. +func newRenderState() *renderState { + return &renderState{ + items: make(map[int]renderedItem), + lines: []string{}, + lastIndex: NotRendered, + finalHeight: NoFinalHeight, + needsRerender: true, + } +} + +// reset clears all cached rendering data and resets state to initial values. +func (rs *renderState) reset() { + rs.items = make(map[int]renderedItem) + rs.lines = []string{} + rs.lastIndex = NotRendered + rs.finalHeight = NoFinalHeight + rs.needsRerender = true +} + +// viewState manages the visual display properties of the list. +type viewState struct { + width, height int // Dimensions of the list viewport + offset int // Current scroll offset in lines + reverse bool // Whether to render in reverse order (bottom-up) + content string // The final rendered content to display +} + +// selectionState manages which item is currently selected. +type selectionState struct { + selectedIndex int // Index of the currently selected item, or NoSelection +} + +// isValidIndex checks if the selected index is within the valid range of items. +func (ss *selectionState) isValidIndex(itemCount int) bool { + return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount +} + +// model is the main implementation of the ListModel interface. +// It coordinates between view state, render state, and selection state. +type model struct { + viewState viewState // Display and scrolling state + renderState *renderState // Rendering cache and state + selectionState selectionState // Item selection state + help help.Model // Help system for keyboard shortcuts + keyMap KeyMap // Key bindings for navigation + allItems []util.Model // The actual list items + gapSize int // Number of empty lines between items + padding []int // Padding around the list content + wrapNavigation bool // Whether to wrap navigation at the ends + + filterable bool // Whether items can be filtered + filterPlaceholder string // Placeholder text for filter input + filteredItems []util.Model // Filtered items based on current search + input textinput.Model // Input field for filtering items + inputStyle lipgloss.Style // Style for the input field + hideFilterInput bool // Whether to hide the filter input field + currentSearch string // Current search term for filtering + + isFocused bool // Whether the list is currently focused +} + +// listOptions is a function type for configuring list options. +type listOptions func(*model) + +// WithKeyMap sets custom key bindings for the list. +func WithKeyMap(k KeyMap) listOptions { + return func(m *model) { + m.keyMap = k + } +} + +// WithReverse sets whether the list should render in reverse order (newest items at bottom). +func WithReverse(reverse bool) listOptions { + return func(m *model) { + m.setReverse(reverse) + } +} + +// WithGapSize sets the number of empty lines to insert between list items. +func WithGapSize(gapSize int) listOptions { + return func(m *model) { + m.gapSize = gapSize + } +} + +// WithPadding sets the padding around the list content. +// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal, +// 4 values = top/right/bottom/left. +func WithPadding(padding ...int) listOptions { + return func(m *model) { + m.padding = padding + } +} + +// WithItems sets the initial items for the list. +func WithItems(items []util.Model) listOptions { + return func(m *model) { + m.allItems = items + m.filteredItems = items // Initially, all items are visible + } +} + +// WithFilterable enables filtering of items based on their FilterValue. +func WithFilterable(filterable bool) listOptions { + return func(m *model) { + m.filterable = filterable + } +} + +// WithHideFilterInput hides the filter input field. +func WithHideFilterInput(hide bool) listOptions { + return func(m *model) { + m.hideFilterInput = hide + } +} + +// WithFilterPlaceholder sets the placeholder text for the filter input field. +func WithFilterPlaceholder(placeholder string) listOptions { + return func(m *model) { + m.filterPlaceholder = placeholder + } +} + +// WithInputStyle sets the style for the filter input field. +func WithInputStyle(style lipgloss.Style) listOptions { + return func(m *model) { + m.inputStyle = style + } +} + +// WithWrapNavigation enables wrapping navigation at the ends of the list. +func WithWrapNavigation(wrap bool) listOptions { + return func(m *model) { + m.wrapNavigation = wrap + } +} + +// New creates a new list model with the specified options. +// The list starts with no items selected and requires SetItems to be called +// or items to be provided via WithItems option. +func New(opts ...listOptions) ListModel { + t := styles.CurrentTheme() + + m := &model{ + help: help.New(), + keyMap: DefaultKeyMap(), + allItems: []util.Model{}, + filteredItems: []util.Model{}, + renderState: newRenderState(), + gapSize: DefaultGapSize, + padding: []int{}, + selectionState: selectionState{selectedIndex: NoSelection}, + filterPlaceholder: "Type to filter...", + inputStyle: t.S().Base.Padding(0, 1, 1, 1), + isFocused: true, + } + for _, opt := range opts { + opt(m) + } + + if m.filterable && !m.hideFilterInput { + t := styles.CurrentTheme() + ti := textinput.New() + ti.Placeholder = m.filterPlaceholder + ti.SetVirtualCursor(false) + ti.Focus() + ti.SetStyles(t.S().TextInput) + m.input = ti + + // disable j,k movements + m.keyMap.NDown.SetEnabled(false) + m.keyMap.NUp.SetEnabled(false) + } + return m +} + +// Init initializes the list component and sets up the initial items. +// This is called automatically by the Bubble Tea framework. +func (m *model) Init() tea.Cmd { + return m.SetItems(m.filteredItems) +} + +// Update handles incoming messages and updates the list state accordingly. +// It processes keyboard input, animation messages, and forwards other messages +// to the currently selected item. +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + return m.handleKeyPress(msg) + case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg: + return m.handleAnimationMsg(msg) + } + if m.selectionState.isValidIndex(len(m.filteredItems)) { + return m.updateSelectedItem(msg) + } + + return m, nil +} + +// View renders the list to a string for display. +// Returns empty string if the list has no dimensions. +// Triggers re-rendering if needed before returning content. +func (m *model) View() tea.View { + if m.viewState.height == 0 || m.viewState.width == 0 { + return tea.NewView("") // No content to display + } + if m.renderState.needsRerender { + m.renderVisible() + } + + content := lipgloss.NewStyle(). + Padding(m.padding...). + Height(m.viewState.height). + Render(m.viewState.content) + + if m.filterable && !m.hideFilterInput { + content = lipgloss.JoinVertical( + lipgloss.Left, + m.inputStyle.Render(m.input.View()), + content, + ) + } + view := tea.NewView(content) + if m.filterable && !m.hideFilterInput { + view.SetCursor(m.input.Cursor()) + } + return view +} + +// handleKeyPress processes keyboard input for list navigation. +// Supports scrolling, item selection, and navigation to top/bottom. +func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keyMap.Down) || key.Matches(msg, m.keyMap.NDown): + m.scrollDown(1) + case key.Matches(msg, m.keyMap.Up) || key.Matches(msg, m.keyMap.NUp): + m.scrollUp(1) + case key.Matches(msg, m.keyMap.DownOneItem): + return m, m.selectNextItem() + case key.Matches(msg, m.keyMap.UpOneItem): + return m, m.selectPreviousItem() + case key.Matches(msg, m.keyMap.HalfPageDown): + m.scrollDown(m.listHeight() / 2) + case key.Matches(msg, m.keyMap.HalfPageUp): + m.scrollUp(m.listHeight() / 2) + case key.Matches(msg, m.keyMap.Home): + return m, m.goToTop() + case key.Matches(msg, m.keyMap.End): + return m, m.goToBottom() + default: + if !m.filterable || m.hideFilterInput { + return m, nil // Ignore other keys if not filterable or input is hidden + } + var cmds []tea.Cmd + u, cmd := m.input.Update(msg) + m.input = u + cmds = append(cmds, cmd) + if m.currentSearch != m.input.Value() { + cmd = m.Filter(m.input.Value()) + cmds = append(cmds, cmd) + } + m.currentSearch = m.input.Value() + return m, tea.Batch(cmds...) + } + return m, nil +} + +// handleAnimationMsg forwards animation messages to items that support animation. +// Only items implementing HasAnim and currently spinning receive these messages. +func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + for inx, item := range m.filteredItems { + if i, ok := item.(HasAnim); ok && i.Spinning() { + updated, cmd := i.Update(msg) + cmds = append(cmds, cmd) + if u, ok := updated.(util.Model); ok { + m.UpdateItem(inx, u) + } + } + } + return m, tea.Batch(cmds...) +} + +// updateSelectedItem forwards messages to the currently selected item. +// This allows the selected item to handle its own input and state changes. +func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg) + cmds = append(cmds, cmd) + if updated, ok := u.(util.Model); ok { + m.UpdateItem(m.selectionState.selectedIndex, updated) + } + return m, tea.Batch(cmds...) +} + +// scrollDown scrolls the list down by the specified amount. +// Direction is automatically adjusted based on reverse mode. +func (m *model) scrollDown(amount int) { + if m.viewState.reverse { + m.decreaseOffset(amount) + } else { + m.increaseOffset(amount) + } +} + +// scrollUp scrolls the list up by the specified amount. +// Direction is automatically adjusted based on reverse mode. +func (m *model) scrollUp(amount int) { + if m.viewState.reverse { + m.increaseOffset(amount) + } else { + m.decreaseOffset(amount) + } +} + +// Items returns a copy of all items in the list. +func (m *model) Items() []util.Model { + return m.filteredItems +} + +// renderVisible determines which rendering strategy to use and triggers rendering. +// Uses forward rendering for normal mode and reverse rendering for reverse mode. +func (m *model) renderVisible() { + if m.viewState.reverse { + m.renderVisibleReverse() + } else { + m.renderVisibleForward() + } +} + +// renderVisibleForward renders items from top to bottom (normal mode). +// Only renders items that are currently visible or near the viewport. +func (m *model) renderVisibleForward() { + renderer := &forwardRenderer{ + model: m, + start: 0, + cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd + items: m.filteredItems, + realIdx: m.renderState.lastIndex, + } + + if m.renderState.lastIndex > NotRendered { + renderer.items = m.filteredItems[m.renderState.lastIndex+1:] + renderer.start = len(m.renderState.lines) + } + + renderer.render() + m.finalizeRender() +} + +// renderVisibleReverse renders items from bottom to top (reverse mode). +// Used when new items should appear at the bottom (like chat messages). +func (m *model) renderVisibleReverse() { + renderer := &reverseRenderer{ + model: m, + start: 0, + cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, + items: m.filteredItems, + realIdx: m.renderState.lastIndex, + } + + if m.renderState.lastIndex > NotRendered { + renderer.items = m.filteredItems[:m.renderState.lastIndex] + renderer.start = len(m.renderState.lines) + } else { + m.renderState.lastIndex = len(m.filteredItems) + renderer.realIdx = len(m.filteredItems) + } + + renderer.render() + m.finalizeRender() +} + +// finalizeRender completes the rendering process by updating scroll bounds and content. +func (m *model) finalizeRender() { + m.renderState.needsRerender = false + if m.renderState.finalHeight > NoFinalHeight { + m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight) + } + m.updateContent() +} + +// updateContent extracts the visible portion of rendered content for display. +// Handles both normal and reverse rendering modes. +func (m *model) updateContent() { + maxHeight := min(m.listHeight(), len(m.renderState.lines)) + if m.viewState.offset >= len(m.renderState.lines) { + m.viewState.content = "" + return + } + + if m.viewState.reverse { + end := len(m.renderState.lines) - m.viewState.offset + start := max(0, end-maxHeight) + m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n") + } else { + endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines)) + m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n") + } +} + +// forwardRenderer handles rendering items from top to bottom. +// It builds up the rendered content incrementally, caching results for performance. +type forwardRenderer struct { + model *model // Reference to the parent list model + start int // Current line position in the overall content + cutoff int // Line position where we can stop rendering + items []util.Model // Items to render (may be a subset) + realIdx int // Real index in the full item list +} + +// render processes items in forward order, building up the rendered content. +func (r *forwardRenderer) render() { + for _, item := range r.items { + r.realIdx++ + if r.start > r.cutoff { + break + } + + itemLines := r.getOrRenderItem(item) + if r.realIdx == len(r.model.filteredItems)-1 { + r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight()) + } + + r.model.renderState.lines = append(r.model.renderState.lines, itemLines...) + r.model.renderState.lastIndex = r.realIdx + r.start += len(itemLines) + } +} + +// getOrRenderItem retrieves cached content or renders the item if not cached. +func (r *forwardRenderer) getOrRenderItem(item util.Model) []string { + if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok { + return cachedContent.lines + } + + itemLines := r.renderItemLines(item) + r.model.renderState.items[r.realIdx] = renderedItem{ + lines: itemLines, + start: r.start, + height: len(itemLines), + } + return itemLines +} + +// renderItemLines converts an item to its string representation with gaps. +func (r *forwardRenderer) renderItemLines(item util.Model) []string { + return r.model.getItemLines(item) +} + +// reverseRenderer handles rendering items from bottom to top. +// Used in reverse mode where new items appear at the bottom. +type reverseRenderer struct { + model *model // Reference to the parent list model + start int // Current line position in the overall content + cutoff int // Line position where we can stop rendering + items []util.Model // Items to render (may be a subset) + realIdx int // Real index in the full item list +} + +// render processes items in reverse order, prepending to the rendered content. +func (r *reverseRenderer) render() { + for i := len(r.items) - 1; i >= 0; i-- { + r.realIdx-- + if r.start > r.cutoff { + break + } + + itemLines := r.getOrRenderItem(r.items[i]) + if r.realIdx == 0 { + r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight()) + } + + r.model.renderState.lines = append(itemLines, r.model.renderState.lines...) + r.model.renderState.lastIndex = r.realIdx + r.start += len(itemLines) + } +} + +// getOrRenderItem retrieves cached content or renders the item if not cached. +func (r *reverseRenderer) getOrRenderItem(item util.Model) []string { + if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok { + return cachedContent.lines + } + + itemLines := r.renderItemLines(item) + r.model.renderState.items[r.realIdx] = renderedItem{ + lines: itemLines, + start: r.start, + height: len(itemLines), + } + return itemLines +} + +// renderItemLines converts an item to its string representation with gaps. +func (r *reverseRenderer) renderItemLines(item util.Model) []string { + return r.model.getItemLines(item) +} + +// selectPreviousItem moves selection to the previous item in the list. +// Handles focus management and ensures the selected item remains visible. +// Skips section headers during navigation. +func (m *model) selectPreviousItem() tea.Cmd { + if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation { + // If at the beginning and wrapping is enabled, go to the last item + return m.goToBottom() + } + if m.selectionState.selectedIndex <= 0 { + return nil + } + + cmds := []tea.Cmd{m.blurSelected()} + m.selectionState.selectedIndex-- + + // Skip section headers + for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) { + m.selectionState.selectedIndex-- + } + + // If we went past the beginning, stay at the first non-header item + if m.selectionState.selectedIndex <= 0 { + cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed + return tea.Batch(cmds...) + } + + cmds = append(cmds, m.focusSelected()) + m.ensureSelectedItemVisible() + return tea.Batch(cmds...) +} + +// selectNextItem moves selection to the next item in the list. +// Handles focus management and ensures the selected item remains visible. +// Skips section headers during navigation. +func (m *model) selectNextItem() tea.Cmd { + if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation { + // If at the end and wrapping is enabled, go to the first item + return m.goToTop() + } + if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 { + return nil + } + + cmds := []tea.Cmd{m.blurSelected()} + m.selectionState.selectedIndex++ + + // Skip section headers + for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) { + m.selectionState.selectedIndex++ + } + + // If we went past the end, stay at the last non-header item + if m.selectionState.selectedIndex >= len(m.filteredItems) { + m.selectionState.selectedIndex = m.findLastSelectableItem() + } + + cmds = append(cmds, m.focusSelected()) + m.ensureSelectedItemVisible() + return tea.Batch(cmds...) +} + +// isSectionHeader checks if the item at the given index is a section header. +func (m *model) isSectionHeader(index int) bool { + if index < 0 || index >= len(m.filteredItems) { + return false + } + if header, ok := m.filteredItems[index].(SectionHeader); ok { + return header.IsSectionHeader() + } + return false +} + +// findFirstSelectableItem finds the first item that is not a section header. +func (m *model) findFirstSelectableItem() int { + for i := range m.filteredItems { + if !m.isSectionHeader(i) { + return i + } + } + return NoSelection +} + +// findLastSelectableItem finds the last item that is not a section header. +func (m *model) findLastSelectableItem() int { + for i := len(m.filteredItems) - 1; i >= 0; i-- { + if !m.isSectionHeader(i) { + return i + } + } + return NoSelection +} + +// ensureSelectedItemVisible scrolls the list to make the selected item visible. +// Uses different strategies for forward and reverse rendering modes. +func (m *model) ensureSelectedItemVisible() { + cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex] + if !ok { + m.renderState.needsRerender = true + return + } + + if m.viewState.reverse { + m.ensureVisibleReverse(cachedItem) + } else { + m.ensureVisibleForward(cachedItem) + } + m.renderState.needsRerender = true +} + +// ensureVisibleForward ensures the selected item is visible in forward rendering mode. +// Handles both large items (taller than viewport) and normal items. +func (m *model) ensureVisibleForward(cachedItem renderedItem) { + if cachedItem.height >= m.listHeight() { + if m.selectionState.selectedIndex > 0 { + changeNeeded := m.viewState.offset - cachedItem.start + m.decreaseOffset(changeNeeded) + } else { + changeNeeded := cachedItem.start - m.viewState.offset + m.increaseOffset(changeNeeded) + } + return + } + + if cachedItem.start < m.viewState.offset { + changeNeeded := m.viewState.offset - cachedItem.start + m.decreaseOffset(changeNeeded) + } else { + end := cachedItem.start + cachedItem.height + if end > m.viewState.offset+m.listHeight() { + changeNeeded := end - (m.viewState.offset + m.listHeight()) + m.increaseOffset(changeNeeded) + } + } +} + +// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode. +// Handles both large items (taller than viewport) and normal items. +func (m *model) ensureVisibleReverse(cachedItem renderedItem) { + if cachedItem.height >= m.listHeight() { + if m.selectionState.selectedIndex < len(m.filteredItems)-1 { + changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight()) + m.decreaseOffset(changeNeeded) + } else { + changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset + m.increaseOffset(changeNeeded) + } + return + } + + if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() { + changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset + m.increaseOffset(changeNeeded) + } else if cachedItem.start < m.viewState.offset { + changeNeeded := m.viewState.offset - cachedItem.start + m.decreaseOffset(changeNeeded) + } +} + +// goToBottom switches to reverse mode and selects the last selectable item. +// Commonly used for chat-like interfaces where new content appears at the bottom. +// Skips section headers when selecting the last item. +func (m *model) goToBottom() tea.Cmd { + cmds := []tea.Cmd{m.blurSelected()} + m.viewState.reverse = true + if m.isFocused { + m.selectionState.selectedIndex = m.findLastSelectableItem() + cmds = append(cmds, m.focusSelected()) + } + m.ResetView() + return tea.Batch(cmds...) +} + +// goToTop switches to forward mode and selects the first selectable item. +// Standard behavior for most list interfaces. +// Skips section headers when selecting the first item. +func (m *model) goToTop() tea.Cmd { + cmds := []tea.Cmd{m.blurSelected()} + m.viewState.reverse = false + m.selectionState.selectedIndex = m.findFirstSelectableItem() + cmds = append(cmds, m.focusSelected()) + m.ResetView() + return tea.Batch(cmds...) +} + +// ResetView clears all cached rendering data and resets scroll position. +// Forces a complete re-render on the next View() call. +func (m *model) ResetView() { + m.renderState.reset() + m.viewState.offset = 0 +} + +// focusSelected gives focus to the currently selected item if it supports focus. +// Triggers a re-render of the item to show its focused state. +func (m *model) focusSelected() tea.Cmd { + if !m.isFocused { + return nil // No focus change if the list is not focused + } + if !m.selectionState.isValidIndex(len(m.filteredItems)) { + return nil + } + if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok { + cmd := i.Focus() + m.rerenderItem(m.selectionState.selectedIndex) + return cmd + } + return nil +} + +// blurSelected removes focus from the currently selected item if it supports focus. +// Triggers a re-render of the item to show its unfocused state. +func (m *model) blurSelected() tea.Cmd { + if !m.selectionState.isValidIndex(len(m.filteredItems)) { + return nil + } + if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok { + cmd := i.Blur() + m.rerenderItem(m.selectionState.selectedIndex) + return cmd + } + return nil +} + +// rerenderItem updates the cached rendering of a specific item. +// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed. +// It efficiently updates only the changed item and adjusts positions of subsequent items if needed. +func (m *model) rerenderItem(inx int) { + if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 { + return + } + + cachedItem, ok := m.renderState.items[inx] + if !ok { + return + } + + rerenderedLines := m.getItemLines(m.filteredItems[inx]) + if slices.Equal(cachedItem.lines, rerenderedLines) { + return + } + + m.updateRenderedLines(cachedItem, rerenderedLines) + m.updateItemPositions(inx, cachedItem, len(rerenderedLines)) + m.updateCachedItem(inx, cachedItem, rerenderedLines) + m.renderState.needsRerender = true +} + +// getItemLines converts an item to its rendered lines, including any gap spacing. +// Handles section headers with special styling. +func (m *model) getItemLines(item util.Model) []string { + var itemLines []string + + itemLines = strings.Split(item.View().String(), "\n") + + if m.gapSize > 0 { + gap := make([]string, m.gapSize) + itemLines = append(itemLines, gap...) + } + return itemLines +} + +// updateRenderedLines replaces the lines for a specific item in the overall rendered content. +func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) { + start, end := m.getItemBounds(cachedItem) + totalLines := len(m.renderState.lines) + + if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines { + m.renderState.lines = slices.Delete(m.renderState.lines, start, end) + m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...) + } +} + +// getItemBounds calculates the start and end line positions for an item. +// Handles both forward and reverse rendering modes. +func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) { + start = cachedItem.start + end = start + cachedItem.height + + if m.viewState.reverse { + totalLines := len(m.renderState.lines) + end = totalLines - cachedItem.start + start = end - cachedItem.height + } + return start, end +} + +// updateItemPositions recalculates positions for items after the changed item. +// This is necessary when an item's height changes, affecting subsequent items. +func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) { + if cachedItem.height == newHeight { + return + } + + if inx == len(m.filteredItems)-1 { + m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight()) + } + + currentStart := cachedItem.start + newHeight + if m.viewState.reverse { + m.updatePositionsReverse(inx, currentStart) + } else { + m.updatePositionsForward(inx, currentStart) + } +} + +// updatePositionsForward updates positions for items after the changed item in forward mode. +func (m *model) updatePositionsForward(inx int, currentStart int) { + for i := inx + 1; i < len(m.filteredItems); i++ { + if existing, ok := m.renderState.items[i]; ok { + existing.start = currentStart + currentStart += existing.height + m.renderState.items[i] = existing + } else { + break + } + } +} + +// updatePositionsReverse updates positions for items before the changed item in reverse mode. +func (m *model) updatePositionsReverse(inx int, currentStart int) { + for i := inx - 1; i >= 0; i-- { + if existing, ok := m.renderState.items[i]; ok { + existing.start = currentStart + currentStart += existing.height + m.renderState.items[i] = existing + } else { + break + } + } +} + +// updateCachedItem updates the cached rendering information for a specific item. +func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) { + m.renderState.items[inx] = renderedItem{ + lines: newLines, + start: cachedItem.start, + height: len(newLines), + } +} + +// increaseOffset scrolls the list down by increasing the offset. +// Respects the final height limit to prevent scrolling past the end. +func (m *model) increaseOffset(n int) { + if m.renderState.finalHeight > NoFinalHeight { + if m.viewState.offset < m.renderState.finalHeight { + m.viewState.offset += n + if m.viewState.offset > m.renderState.finalHeight { + m.viewState.offset = m.renderState.finalHeight + } + m.renderState.needsRerender = true + } + } else { + m.viewState.offset += n + m.renderState.needsRerender = true + } +} + +// decreaseOffset scrolls the list up by decreasing the offset. +// Prevents scrolling above the beginning of the list. +func (m *model) decreaseOffset(n int) { + if m.viewState.offset > 0 { + m.viewState.offset -= n + if m.viewState.offset < 0 { + m.viewState.offset = 0 + } + m.renderState.needsRerender = true + } +} + +// UpdateItem replaces an item at the specified index with a new item. +// Handles focus management and triggers re-rendering as needed. +func (m *model) UpdateItem(inx int, item util.Model) { + if inx < 0 || inx >= len(m.filteredItems) { + return + } + m.filteredItems[inx] = item + if m.selectionState.selectedIndex == inx { + m.focusSelected() + } + m.setItemSize(inx) + m.rerenderItem(inx) + m.renderState.needsRerender = true +} + +// GetSize returns the current dimensions of the list. +func (m *model) GetSize() (int, int) { + return m.viewState.width, m.viewState.height +} + +// SetSize updates the list dimensions and triggers a complete re-render. +// Also updates the size of all items that support sizing. +func (m *model) SetSize(width int, height int) tea.Cmd { + if m.filterable && !m.hideFilterInput { + height -= 2 // adjust for input field height and border + } + + if m.viewState.width == width && m.viewState.height == height { + return nil + } + if m.viewState.height != height { + m.renderState.finalHeight = NoFinalHeight + m.viewState.height = height + } + m.viewState.width = width + m.ResetView() + if m.filterable && !m.hideFilterInput { + m.input.SetWidth(m.getItemWidth() - 5) + } + return m.setAllItemsSize() +} + +// getItemWidth calculates the available width for items, accounting for padding. +func (m *model) getItemWidth() int { + width := m.viewState.width + switch len(m.padding) { + case 1: + width -= m.padding[0] * 2 + case 2, 3: + width -= m.padding[1] * 2 + case 4: + width -= m.padding[1] + m.padding[3] + } + return max(0, width) +} + +// setItemSize updates the size of a specific item if it supports sizing. +func (m *model) setItemSize(inx int) tea.Cmd { + if inx < 0 || inx >= len(m.filteredItems) { + return nil + } + if i, ok := m.filteredItems[inx].(layout.Sizeable); ok { + return i.SetSize(m.getItemWidth(), 0) + } + return nil +} + +// setAllItemsSize updates the size of all items that support sizing. +func (m *model) setAllItemsSize() tea.Cmd { + var cmds []tea.Cmd + for i := range m.filteredItems { + if cmd := m.setItemSize(i); cmd != nil { + cmds = append(cmds, cmd) + } + } + return tea.Batch(cmds...) +} + +// listHeight calculates the available height for list content, accounting for padding. +func (m *model) listHeight() int { + height := m.viewState.height + switch len(m.padding) { + case 1: + height -= m.padding[0] * 2 + case 2: + height -= m.padding[0] * 2 + case 3, 4: + height -= m.padding[0] + m.padding[2] + } + if m.filterable && !m.hideFilterInput { + height -= lipgloss.Height(m.inputStyle.Render("dummy")) + } + return max(0, height) +} + +// AppendItem adds a new item to the end of the list. +// Automatically switches to reverse mode and scrolls to show the new item. +func (m *model) AppendItem(item util.Model) tea.Cmd { + cmds := []tea.Cmd{ + item.Init(), + } + m.allItems = append(m.allItems, item) + m.filteredItems = m.allItems + cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1)) + cmds = append(cmds, m.goToBottom()) + m.renderState.needsRerender = true + return tea.Batch(cmds...) +} + +// DeleteItem removes an item at the specified index. +// Adjusts selection if necessary and triggers a complete re-render. +func (m *model) DeleteItem(i int) { + if i < 0 || i >= len(m.filteredItems) { + return + } + m.allItems = slices.Delete(m.allItems, i, i+1) + delete(m.renderState.items, i) + m.filteredItems = m.allItems + + if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 { + m.selectionState.selectedIndex-- + } else if m.selectionState.selectedIndex > i { + m.selectionState.selectedIndex-- + } + + m.ResetView() + m.renderState.needsRerender = true +} + +// PrependItem adds a new item to the beginning of the list. +// Adjusts cached positions and selection index, then switches to forward mode. +func (m *model) PrependItem(item util.Model) tea.Cmd { + cmds := []tea.Cmd{item.Init()} + m.allItems = append([]util.Model{item}, m.allItems...) + m.filteredItems = m.allItems + + // Shift all cached item indices by 1 + newItems := make(map[int]renderedItem, len(m.renderState.items)) + for k, v := range m.renderState.items { + newItems[k+1] = v + } + m.renderState.items = newItems + + if m.selectionState.selectedIndex >= 0 { + m.selectionState.selectedIndex++ + } + + cmds = append(cmds, m.goToTop()) + cmds = append(cmds, m.setItemSize(0)) + m.renderState.needsRerender = true + return tea.Batch(cmds...) +} + +// setReverse switches between forward and reverse rendering modes. +func (m *model) setReverse(reverse bool) { + if reverse { + m.goToBottom() + } else { + m.goToTop() + } +} + +// SetItems replaces all items in the list with a new set. +// Initializes all items, sets their sizes, and establishes initial selection. +// Ensures the initial selection skips section headers. +func (m *model) SetItems(items []util.Model) tea.Cmd { + m.allItems = items + m.filteredItems = items + cmds := []tea.Cmd{m.setAllItemsSize()} + + for _, item := range m.filteredItems { + cmds = append(cmds, item.Init()) + } + + if len(m.filteredItems) > 0 { + if m.viewState.reverse { + m.selectionState.selectedIndex = m.findLastSelectableItem() + } else { + m.selectionState.selectedIndex = m.findFirstSelectableItem() + } + if cmd := m.focusSelected(); cmd != nil { + cmds = append(cmds, cmd) + } + } else { + m.selectionState.selectedIndex = NoSelection + } + + m.ResetView() + return tea.Batch(cmds...) +} + +// section represents a group of items under a section header. +type section struct { + header SectionHeader + items []util.Model +} + +// parseSections parses the flat item list into sections. +func (m *model) parseSections() []section { + var sections []section + var currentSection *section + + for _, item := range m.allItems { + if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() { + // Start a new section + if currentSection != nil { + sections = append(sections, *currentSection) + } + currentSection = §ion{ + header: header, + items: []util.Model{}, + } + } else if currentSection != nil { + // Add item to current section + currentSection.items = append(currentSection.items, item) + } else { + // Item without a section header - create an implicit section + if len(sections) == 0 || sections[len(sections)-1].header != nil { + sections = append(sections, section{ + header: nil, + items: []util.Model{item}, + }) + } else { + // Add to the last implicit section + sections[len(sections)-1].items = append(sections[len(sections)-1].items, item) + } + } + } + + // Don't forget the last section + if currentSection != nil { + sections = append(sections, *currentSection) + } + + return sections +} + +// flattenSections converts sections back to a flat list. +func (m *model) flattenSections(sections []section) []util.Model { + var result []util.Model + + for _, sect := range sections { + if sect.header != nil { + result = append(result, sect.header) + } + result = append(result, sect.items...) + } + + return result +} + +func (m *model) Filter(search string) tea.Cmd { + var cmds []tea.Cmd + search = strings.TrimSpace(search) + search = strings.ToLower(search) + + // Clear focus and match indexes from all items + for _, item := range m.allItems { + if i, ok := item.(layout.Focusable); ok { + cmds = append(cmds, i.Blur()) + } + if i, ok := item.(HasMatchIndexes); ok { + i.MatchIndexes(make([]int, 0)) + } + } + + if search == "" { + cmds = append(cmds, m.SetItems(m.allItems)) + return tea.Batch(cmds...) + } + + // Parse items into sections + sections := m.parseSections() + var filteredSections []section + + for _, sect := range sections { + filteredSection := m.filterSection(sect, search) + if filteredSection != nil { + filteredSections = append(filteredSections, *filteredSection) + } + } + + // Rebuild flat list from filtered sections + m.filteredItems = m.flattenSections(filteredSections) + + // Set initial selection + if len(m.filteredItems) > 0 { + if m.viewState.reverse { + slices.Reverse(m.filteredItems) + m.selectionState.selectedIndex = m.findLastSelectableItem() + } else { + m.selectionState.selectedIndex = m.findFirstSelectableItem() + } + if cmd := m.focusSelected(); cmd != nil { + cmds = append(cmds, cmd) + } + } else { + m.selectionState.selectedIndex = NoSelection + } + + m.ResetView() + return tea.Batch(cmds...) +} + +// filterSection filters items within a section and returns the section if it has matches. +func (m *model) filterSection(sect section, search string) *section { + var matchedItems []util.Model + var hasHeaderMatch bool + + // Check if section header itself matches + if sect.header != nil { + headerText := strings.ToLower(sect.header.View().String()) + if strings.Contains(headerText, search) { + hasHeaderMatch = true + // If header matches, include all items in the section + matchedItems = sect.items + } + } + + // If header didn't match, filter items within the section + if !hasHeaderMatch && len(sect.items) > 0 { + // Create words array for items in this section + words := make([]string, len(sect.items)) + for i, item := range sect.items { + if f, ok := item.(HasFilterValue); ok { + words[i] = strings.ToLower(f.FilterValue()) + } else { + words[i] = "" + } + } + + // Find matches within this section + matches := fuzzy.Find(search, words) + + // Sort matches by score but preserve relative order for equal scores + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + + // Build matched items list + for _, match := range matches { + item := sect.items[match.Index] + if i, ok := item.(HasMatchIndexes); ok { + i.MatchIndexes(match.MatchedIndexes) + } + matchedItems = append(matchedItems, item) + } + } + + // Return section only if it has matches + if len(matchedItems) > 0 { + return §ion{ + header: sect.header, + items: matchedItems, + } + } + + return nil +} + +// SelectedIndex returns the index of the currently selected item. +func (m *model) SelectedIndex() int { + if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) { + return NoSelection + } + return m.selectionState.selectedIndex +} + +// SetSelected sets the selected item by index and automatically scrolls to make it visible. +// If the index is invalid or points to a section header, it finds the nearest selectable item. +func (m *model) SetSelected(index int) tea.Cmd { + changeNeeded := m.selectionState.selectedIndex - index + cmds := []tea.Cmd{} + if changeNeeded < 0 { + for range -changeNeeded { + cmds = append(cmds, m.selectNextItem()) + m.renderVisible() + } + } else if changeNeeded > 0 { + for range changeNeeded { + cmds = append(cmds, m.selectPreviousItem()) + m.renderVisible() + } + } + return tea.Batch(cmds...) +} + +// Blur implements ListModel. +func (m *model) Blur() tea.Cmd { + m.isFocused = false + cmd := m.blurSelected() + return cmd +} + +// Focus implements ListModel. +func (m *model) Focus() tea.Cmd { + m.isFocused = true + cmd := m.focusSelected() + return cmd +} + +// IsFocused implements ListModel. +func (m *model) IsFocused() bool { + return m.isFocused +} diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go deleted file mode 100644 index 0dc227a80ebbbcdd8a4e3578a11b1fe6f64aca4a..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/status.go +++ /dev/null @@ -1,293 +0,0 @@ -package core - -import ( - "fmt" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/lsp" - "github.com/opencode-ai/opencode/internal/lsp/protocol" - "github.com/opencode-ai/opencode/internal/pubsub" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/components/chat" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -type StatusCmp interface { - tea.Model -} - -type statusCmp struct { - info util.InfoMsg - width int - messageTTL time.Duration - lspClients map[string]*lsp.Client - session session.Session -} - -// clearMessageCmd is a command that clears status messages after a timeout -func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd { - return tea.Tick(ttl, func(time.Time) tea.Msg { - return util.ClearStatusMsg{} - }) -} - -func (m statusCmp) Init() tea.Cmd { - return nil -} - -func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - return m, nil - case chat.SessionSelectedMsg: - m.session = msg - case chat.SessionClearedMsg: - m.session = session.Session{} - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent { - if m.session.ID == msg.Payload.ID { - m.session = msg.Payload - } - } - case util.InfoMsg: - m.info = msg - ttl := msg.TTL - if ttl == 0 { - ttl = m.messageTTL - } - return m, m.clearMessageCmd(ttl) - case util.ClearStatusMsg: - m.info = util.InfoMsg{} - } - return m, nil -} - -var helpWidget = "" - -// getHelpWidget returns the help widget with current theme colors -func getHelpWidget() string { - t := theme.CurrentTheme() - helpText := "ctrl+? help" - - return styles.Padded(). - Background(t.TextMuted()). - Foreground(t.BackgroundDarker()). - Bold(true). - Render(helpText) -} - -func formatTokensAndCost(tokens, contextWindow int64, cost float64) string { - // Format tokens in human-readable format (e.g., 110K, 1.2M) - var formattedTokens string - switch { - case tokens >= 1_000_000: - formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) - case tokens >= 1_000: - formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) - default: - formattedTokens = fmt.Sprintf("%d", tokens) - } - - // Remove .0 suffix if present - if strings.HasSuffix(formattedTokens, ".0K") { - formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) - } - if strings.HasSuffix(formattedTokens, ".0M") { - formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) - } - - // Format cost with $ symbol and 2 decimal places - formattedCost := fmt.Sprintf("$%.2f", cost) - - percentage := (float64(tokens) / float64(contextWindow)) * 100 - if percentage > 80 { - // add the warning icon and percentage - formattedTokens = fmt.Sprintf("%s(%d%%)", styles.WarningIcon, int(percentage)) - } - - return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost) -} - -func (m statusCmp) View() string { - t := theme.CurrentTheme() - modelID := config.Get().Agents[config.AgentCoder].Model - model := models.SupportedModels[modelID] - - // Initialize the help widget - status := getHelpWidget() - - tokenInfoWidth := 0 - if m.session.ID != "" { - totalTokens := m.session.PromptTokens + m.session.CompletionTokens - tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost) - tokensStyle := styles.Padded(). - Background(t.Text()). - Foreground(t.BackgroundSecondary()) - percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100 - if percentage > 80 { - tokensStyle = tokensStyle.Background(t.Warning()) - } - tokenInfoWidth = lipgloss.Width(tokens) + 2 - status += tokensStyle.Render(tokens) - } - - diagnostics := styles.Padded(). - Background(t.BackgroundDarker()). - Render(m.projectDiagnostics()) - - availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth) - - if m.info.Msg != "" { - infoStyle := styles.Padded(). - Foreground(t.Background()). - Width(availableWidht) - - switch m.info.Type { - case util.InfoTypeInfo: - infoStyle = infoStyle.Background(t.Info()) - case util.InfoTypeWarn: - infoStyle = infoStyle.Background(t.Warning()) - case util.InfoTypeError: - infoStyle = infoStyle.Background(t.Error()) - } - - infoWidth := availableWidht - 10 - // Truncate message if it's longer than available width - msg := m.info.Msg - if len(msg) > infoWidth && infoWidth > 0 { - msg = msg[:infoWidth] + "..." - } - status += infoStyle.Render(msg) - } else { - status += styles.Padded(). - Foreground(t.Text()). - Background(t.BackgroundSecondary()). - Width(availableWidht). - Render("") - } - - status += diagnostics - status += m.model() - return status -} - -func (m *statusCmp) projectDiagnostics() string { - t := theme.CurrentTheme() - - // Check if any LSP server is still initializing - initializing := false - for _, client := range m.lspClients { - if client.GetServerState() == lsp.StateStarting { - initializing = true - break - } - } - - // If any server is initializing, show that status - if initializing { - return lipgloss.NewStyle(). - Background(t.BackgroundDarker()). - Foreground(t.Warning()). - Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon)) - } - - errorDiagnostics := []protocol.Diagnostic{} - warnDiagnostics := []protocol.Diagnostic{} - hintDiagnostics := []protocol.Diagnostic{} - infoDiagnostics := []protocol.Diagnostic{} - for _, client := range m.lspClients { - for _, d := range client.GetDiagnostics() { - for _, diag := range d { - switch diag.Severity { - case protocol.SeverityError: - errorDiagnostics = append(errorDiagnostics, diag) - case protocol.SeverityWarning: - warnDiagnostics = append(warnDiagnostics, diag) - case protocol.SeverityHint: - hintDiagnostics = append(hintDiagnostics, diag) - case protocol.SeverityInformation: - infoDiagnostics = append(infoDiagnostics, diag) - } - } - } - } - - if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 { - return "No diagnostics" - } - - diagnostics := []string{} - - if len(errorDiagnostics) > 0 { - errStr := lipgloss.NewStyle(). - Background(t.BackgroundDarker()). - Foreground(t.Error()). - Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics))) - diagnostics = append(diagnostics, errStr) - } - if len(warnDiagnostics) > 0 { - warnStr := lipgloss.NewStyle(). - Background(t.BackgroundDarker()). - Foreground(t.Warning()). - Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics))) - diagnostics = append(diagnostics, warnStr) - } - if len(hintDiagnostics) > 0 { - hintStr := lipgloss.NewStyle(). - Background(t.BackgroundDarker()). - Foreground(t.Text()). - Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics))) - diagnostics = append(diagnostics, hintStr) - } - if len(infoDiagnostics) > 0 { - infoStr := lipgloss.NewStyle(). - Background(t.BackgroundDarker()). - Foreground(t.Info()). - Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics))) - diagnostics = append(diagnostics, infoStr) - } - - return strings.Join(diagnostics, " ") -} - -func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int { - tokensWidth := 0 - if m.session.ID != "" { - tokensWidth = lipgloss.Width(tokenInfo) + 2 - } - return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth) -} - -func (m statusCmp) model() string { - t := theme.CurrentTheme() - - cfg := config.Get() - - coder, ok := cfg.Agents[config.AgentCoder] - if !ok { - return "Unknown" - } - model := models.SupportedModels[coder.Model] - - return styles.Padded(). - Background(t.Secondary()). - Foreground(t.Background()). - Render(model.Name) -} - -func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp { - helpWidget = getHelpWidget() - - return &statusCmp{ - messageTTL: 10 * time.Second, - lspClients: lspClients, - } -} diff --git a/internal/tui/components/core/status/keys.go b/internal/tui/components/core/status/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..f2572ed1745a927aa0158c45ae1ba3228a67446f --- /dev/null +++ b/internal/tui/components/core/status/keys.go @@ -0,0 +1,55 @@ +package status + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + Tab, + Commands, + Sessions, + Help key.Binding +} + +func DefaultKeyMap(tabHelp string) KeyMap { + return KeyMap{ + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", tabHelp), + ), + Commands: key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "commands"), + ), + Sessions: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), + Help: key.NewBinding( + key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"), + key.WithHelp("ctrl+?", "more"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Tab, + k.Commands, + k.Sessions, + k.Help, + } +} diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go new file mode 100644 index 0000000000000000000000000000000000000000..ef5ebef108e9252aefa353fddefdde3538a28497 --- /dev/null +++ b/internal/tui/components/core/status/status.go @@ -0,0 +1,113 @@ +package status + +import ( + "time" + + "github.com/charmbracelet/bubbles/v2/help" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" +) + +type StatusCmp interface { + util.Model +} + +type statusCmp struct { + info util.InfoMsg + width int + messageTTL time.Duration + session session.Session + help help.Model +} + +// clearMessageCmd is a command that clears status messages after a timeout +func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd { + return tea.Tick(ttl, func(time.Time) tea.Msg { + return util.ClearStatusMsg{} + }) +} + +func (m statusCmp) Init() tea.Cmd { + return nil +} + +func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + return m, nil + + // Handle status info + case util.InfoMsg: + m.info = msg + ttl := msg.TTL + if ttl == 0 { + ttl = m.messageTTL + } + return m, m.clearMessageCmd(ttl) + case util.ClearStatusMsg: + m.info = util.InfoMsg{} + + // Handle persistent logs + case pubsub.Event[logging.LogMessage]: + if msg.Payload.Persist { + switch msg.Payload.Level { + case "error": + m.info = util.InfoMsg{ + Type: util.InfoTypeError, + Msg: msg.Payload.Message, + TTL: msg.Payload.PersistTime, + } + case "info": + m.info = util.InfoMsg{ + Type: util.InfoTypeInfo, + Msg: msg.Payload.Message, + TTL: msg.Payload.PersistTime, + } + case "warn": + m.info = util.InfoMsg{ + Type: util.InfoTypeWarn, + Msg: msg.Payload.Message, + TTL: msg.Payload.PersistTime, + } + default: + m.info = util.InfoMsg{ + Type: util.InfoTypeInfo, + Msg: msg.Payload.Message, + TTL: msg.Payload.PersistTime, + } + } + } + } + return m, nil +} + +func (m statusCmp) View() tea.View { + t := styles.CurrentTheme() + status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(DefaultKeyMap("focus chat"))) + if m.info.Msg != "" { + switch m.info.Type { + case util.InfoTypeError: + status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg) + case util.InfoTypeWarn: + status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg) + default: + status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg) + } + } + return tea.NewView(status) +} + +func NewStatusCmp() StatusCmp { + t := styles.CurrentTheme() + help := help.New() + help.Styles = t.S().Help + return &statusCmp{ + messageTTL: 10 * time.Second, + help: help, + } +} diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go deleted file mode 100644 index 684d8662fcc357adb33f873da9eadab1d3ca6a67..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/arguments.go +++ /dev/null @@ -1,257 +0,0 @@ -package dialog - -import ( - "fmt" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -type argumentsDialogKeyMap struct { - Enter key.Binding - Escape key.Binding -} - -// ShortHelp implements key.Map. -func (k argumentsDialogKeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm"), - ), - key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// FullHelp implements key.Map. -func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{k.ShortHelp()} -} - -// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog. -type ShowMultiArgumentsDialogMsg struct { - CommandID string - Content string - ArgNames []string -} - -// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed. -type CloseMultiArgumentsDialogMsg struct { - Submit bool - CommandID string - Content string - Args map[string]string -} - -// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments. -type MultiArgumentsDialogCmp struct { - width, height int - inputs []textinput.Model - focusIndex int - keys argumentsDialogKeyMap - commandID string - content string - argNames []string -} - -// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp. -func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp { - t := theme.CurrentTheme() - inputs := make([]textinput.Model, len(argNames)) - - for i, name := range argNames { - ti := textinput.New() - ti.Placeholder = fmt.Sprintf("Enter value for %s...", name) - ti.Width = 40 - ti.Prompt = "" - ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background()) - ti.PromptStyle = ti.PromptStyle.Background(t.Background()) - ti.TextStyle = ti.TextStyle.Background(t.Background()) - - // Only focus the first input initially - if i == 0 { - ti.Focus() - ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary()) - ti.TextStyle = ti.TextStyle.Foreground(t.Primary()) - } else { - ti.Blur() - } - - inputs[i] = ti - } - - return MultiArgumentsDialogCmp{ - inputs: inputs, - keys: argumentsDialogKeyMap{}, - commandID: commandID, - content: content, - argNames: argNames, - focusIndex: 0, - } -} - -// Init implements tea.Model. -func (m MultiArgumentsDialogCmp) Init() tea.Cmd { - // Make sure only the first input is focused - for i := range m.inputs { - if i == 0 { - m.inputs[i].Focus() - } else { - m.inputs[i].Blur() - } - } - - return textinput.Blink -} - -// Update implements tea.Model. -func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - t := theme.CurrentTheme() - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): - return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{ - Submit: false, - CommandID: m.commandID, - Content: m.content, - Args: nil, - }) - case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): - // If we're on the last input, submit the form - if m.focusIndex == len(m.inputs)-1 { - args := make(map[string]string) - for i, name := range m.argNames { - args[name] = m.inputs[i].Value() - } - return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{ - Submit: true, - CommandID: m.commandID, - Content: m.content, - Args: args, - }) - } - // Otherwise, move to the next input - m.inputs[m.focusIndex].Blur() - m.focusIndex++ - m.inputs[m.focusIndex].Focus() - m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary()) - m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary()) - case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): - // Move to the next input - m.inputs[m.focusIndex].Blur() - m.focusIndex = (m.focusIndex + 1) % len(m.inputs) - m.inputs[m.focusIndex].Focus() - m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary()) - m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary()) - case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))): - // Move to the previous input - m.inputs[m.focusIndex].Blur() - m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs) - m.inputs[m.focusIndex].Focus() - m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary()) - m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary()) - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - } - - // Update the focused input - var cmd tea.Cmd - m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -// View implements tea.Model. -func (m MultiArgumentsDialogCmp) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - // Calculate width needed for content - maxWidth := 60 // Width for explanation text - - title := lipgloss.NewStyle(). - Foreground(t.Primary()). - Bold(true). - Width(maxWidth). - Padding(0, 1). - Background(t.Background()). - Render("Command Arguments") - - explanation := lipgloss.NewStyle(). - Foreground(t.Text()). - Width(maxWidth). - Padding(0, 1). - Background(t.Background()). - Render("This command requires multiple arguments. Please enter values for each:") - - // Create input fields for each argument - inputFields := make([]string, len(m.inputs)) - for i, input := range m.inputs { - // Highlight the label of the focused input - labelStyle := lipgloss.NewStyle(). - Width(maxWidth). - Padding(1, 1, 0, 1). - Background(t.Background()) - - if i == m.focusIndex { - labelStyle = labelStyle.Foreground(t.Primary()).Bold(true) - } else { - labelStyle = labelStyle.Foreground(t.TextMuted()) - } - - label := labelStyle.Render(m.argNames[i] + ":") - - field := lipgloss.NewStyle(). - Foreground(t.Text()). - Width(maxWidth). - Padding(0, 1). - Background(t.Background()). - Render(input.View()) - - inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field) - } - - maxWidth = min(maxWidth, m.width-10) - - // Join all elements vertically - elements := []string{title, explanation} - elements = append(elements, inputFields...) - - content := lipgloss.JoinVertical( - lipgloss.Left, - elements..., - ) - - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Background(t.Background()). - Width(lipgloss.Width(content) + 4). - Render(content) -} - -// SetSize sets the size of the component. -func (m *MultiArgumentsDialogCmp) SetSize(width, height int) { - m.width = width - m.height = height -} - -// Bindings implements layout.Bindings. -func (m MultiArgumentsDialogCmp) Bindings() []key.Binding { - return m.keys.ShortHelp() -} \ No newline at end of file diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go deleted file mode 100644 index 25069b8a6dd39633e46b8627ff4a066bc52b1239..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/commands.go +++ /dev/null @@ -1,180 +0,0 @@ -package dialog - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// Command represents a command that can be executed -type Command struct { - ID string - Title string - Description string - Handler func(cmd Command) tea.Cmd -} - -func (ci Command) Render(selected bool, width int) string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - descStyle := baseStyle.Width(width).Foreground(t.TextMuted()) - itemStyle := baseStyle.Width(width). - Foreground(t.Text()). - Background(t.Background()) - - if selected { - itemStyle = itemStyle. - Background(t.Primary()). - Foreground(t.Background()). - Bold(true) - descStyle = descStyle. - Background(t.Primary()). - Foreground(t.Background()) - } - - title := itemStyle.Padding(0, 1).Render(ci.Title) - if ci.Description != "" { - description := descStyle.Padding(0, 1).Render(ci.Description) - return lipgloss.JoinVertical(lipgloss.Left, title, description) - } - return title -} - -// CommandSelectedMsg is sent when a command is selected -type CommandSelectedMsg struct { - Command Command -} - -// CloseCommandDialogMsg is sent when the command dialog is closed -type CloseCommandDialogMsg struct{} - -// CommandDialog interface for the command selection dialog -type CommandDialog interface { - tea.Model - layout.Bindings - SetCommands(commands []Command) -} - -type commandDialogCmp struct { - listView utilComponents.SimpleList[Command] - width int - height int -} - -type commandKeyMap struct { - Enter key.Binding - Escape key.Binding -} - -var commandKeys = commandKeyMap{ - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select command"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), - ), -} - -func (c *commandDialogCmp) Init() tea.Cmd { - return c.listView.Init() -} - -func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, commandKeys.Enter): - selectedItem, idx := c.listView.GetSelectedItem() - if idx != -1 { - return c, util.CmdHandler(CommandSelectedMsg{ - Command: selectedItem, - }) - } - case key.Matches(msg, commandKeys.Escape): - return c, util.CmdHandler(CloseCommandDialogMsg{}) - } - case tea.WindowSizeMsg: - c.width = msg.Width - c.height = msg.Height - } - - u, cmd := c.listView.Update(msg) - c.listView = u.(utilComponents.SimpleList[Command]) - cmds = append(cmds, cmd) - - return c, tea.Batch(cmds...) -} - -func (c *commandDialogCmp) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - maxWidth := 40 - - commands := c.listView.GetItems() - - for _, cmd := range commands { - if len(cmd.Title) > maxWidth-4 { - maxWidth = len(cmd.Title) + 4 - } - if cmd.Description != "" { - if len(cmd.Description) > maxWidth-4 { - maxWidth = len(cmd.Description) + 4 - } - } - } - - c.listView.SetMaxWidth(maxWidth) - - title := baseStyle. - Foreground(t.Primary()). - Bold(true). - Width(maxWidth). - Padding(0, 1). - Render("Commands") - - content := lipgloss.JoinVertical( - lipgloss.Left, - title, - baseStyle.Width(maxWidth).Render(""), - baseStyle.Width(maxWidth).Render(c.listView.View()), - baseStyle.Width(maxWidth).Render(""), - ) - - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) -} - -func (c *commandDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(commandKeys) -} - -func (c *commandDialogCmp) SetCommands(commands []Command) { - c.listView.SetItems(commands) -} - -// NewCommandDialogCmp creates a new command selection dialog -func NewCommandDialogCmp() CommandDialog { - listView := utilComponents.NewSimpleList[Command]( - []Command{}, - 10, - "No commands available", - true, - ) - return &commandDialogCmp{ - listView: listView, - } -} diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go deleted file mode 100644 index 1ce66e12ae79eee479c45a7e6138123f1c77879c..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/complete.go +++ /dev/null @@ -1,264 +0,0 @@ -package dialog - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textarea" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/logging" - utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -type CompletionItem struct { - title string - Title string - Value string -} - -type CompletionItemI interface { - utilComponents.SimpleListItem - GetValue() string - DisplayValue() string -} - -func (ci *CompletionItem) Render(selected bool, width int) string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - itemStyle := baseStyle. - Width(width). - Padding(0, 1) - - if selected { - itemStyle = itemStyle. - Background(t.Background()). - Foreground(t.Primary()). - Bold(true) - } - - title := itemStyle.Render( - ci.GetValue(), - ) - - return title -} - -func (ci *CompletionItem) DisplayValue() string { - return ci.Title -} - -func (ci *CompletionItem) GetValue() string { - return ci.Value -} - -func NewCompletionItem(completionItem CompletionItem) CompletionItemI { - return &completionItem -} - -type CompletionProvider interface { - GetId() string - GetEntry() CompletionItemI - GetChildEntries(query string) ([]CompletionItemI, error) -} - -type CompletionSelectedMsg struct { - SearchString string - CompletionValue string -} - -type CompletionDialogCompleteItemMsg struct { - Value string -} - -type CompletionDialogCloseMsg struct{} - -type CompletionDialog interface { - tea.Model - layout.Bindings - SetWidth(width int) -} - -type completionDialogCmp struct { - query string - completionProvider CompletionProvider - width int - height int - pseudoSearchTextArea textarea.Model - listView utilComponents.SimpleList[CompletionItemI] -} - -type completionDialogKeyMap struct { - Complete key.Binding - Cancel key.Binding -} - -var completionDialogKeys = completionDialogKeyMap{ - Complete: key.NewBinding( - key.WithKeys("tab", "enter"), - ), - Cancel: key.NewBinding( - key.WithKeys(" ", "esc", "backspace"), - ), -} - -func (c *completionDialogCmp) Init() tea.Cmd { - return nil -} - -func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd { - value := c.pseudoSearchTextArea.Value() - - if value == "" { - return nil - } - - return tea.Batch( - util.CmdHandler(CompletionSelectedMsg{ - SearchString: value, - CompletionValue: item.GetValue(), - }), - c.close(), - ) -} - -func (c *completionDialogCmp) close() tea.Cmd { - c.listView.SetItems([]CompletionItemI{}) - c.pseudoSearchTextArea.Reset() - c.pseudoSearchTextArea.Blur() - - return util.CmdHandler(CompletionDialogCloseMsg{}) -} - -func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.KeyMsg: - if c.pseudoSearchTextArea.Focused() { - - if !key.Matches(msg, completionDialogKeys.Complete) { - - var cmd tea.Cmd - c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg) - cmds = append(cmds, cmd) - - var query string - query = c.pseudoSearchTextArea.Value() - if query != "" { - query = query[1:] - } - - if query != c.query { - logging.Info("Query", query) - items, err := c.completionProvider.GetChildEntries(query) - if err != nil { - logging.Error("Failed to get child entries", err) - } - - c.listView.SetItems(items) - c.query = query - } - - u, cmd := c.listView.Update(msg) - c.listView = u.(utilComponents.SimpleList[CompletionItemI]) - - cmds = append(cmds, cmd) - } - - switch { - case key.Matches(msg, completionDialogKeys.Complete): - item, i := c.listView.GetSelectedItem() - if i == -1 { - return c, nil - } - - cmd := c.complete(item) - - return c, cmd - case key.Matches(msg, completionDialogKeys.Cancel): - // Only close on backspace when there are no characters left - if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 { - return c, c.close() - } - } - - return c, tea.Batch(cmds...) - } else { - items, err := c.completionProvider.GetChildEntries("") - if err != nil { - logging.Error("Failed to get child entries", err) - } - - c.listView.SetItems(items) - c.pseudoSearchTextArea.SetValue(msg.String()) - return c, c.pseudoSearchTextArea.Focus() - } - case tea.WindowSizeMsg: - c.width = msg.Width - c.height = msg.Height - } - - return c, tea.Batch(cmds...) -} - -func (c *completionDialogCmp) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - maxWidth := 40 - - completions := c.listView.GetItems() - - for _, cmd := range completions { - title := cmd.DisplayValue() - if len(title) > maxWidth-4 { - maxWidth = len(title) + 4 - } - } - - c.listView.SetMaxWidth(maxWidth) - - return baseStyle.Padding(0, 0). - Border(lipgloss.NormalBorder()). - BorderBottom(false). - BorderRight(false). - BorderLeft(false). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(c.width). - Render(c.listView.View()) -} - -func (c *completionDialogCmp) SetWidth(width int) { - c.width = width -} - -func (c *completionDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(completionDialogKeys) -} - -func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog { - ti := textarea.New() - - items, err := completionProvider.GetChildEntries("") - if err != nil { - logging.Error("Failed to get child entries", err) - } - - li := utilComponents.NewSimpleList( - items, - 7, - "No file matches found", - false, - ) - - return &completionDialogCmp{ - query: "", - completionProvider: completionProvider, - pseudoSearchTextArea: ti, - listView: li, - } -} diff --git a/internal/tui/components/dialog/custom_commands.go b/internal/tui/components/dialog/custom_commands.go deleted file mode 100644 index 049c4735b5bc4302da87d349d885756882e3c14f..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/custom_commands.go +++ /dev/null @@ -1,186 +0,0 @@ -package dialog - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// Command prefix constants -const ( - UserCommandPrefix = "user:" - ProjectCommandPrefix = "project:" -) - -// namedArgPattern is a regex pattern to find named arguments in the format $NAME -var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) - -// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory -func LoadCustomCommands() ([]Command, error) { - cfg := config.Get() - if cfg == nil { - return nil, fmt.Errorf("config not loaded") - } - - var commands []Command - - // Load user commands from XDG_CONFIG_HOME/opencode/commands - xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") - if xdgConfigHome == "" { - // Default to ~/.config if XDG_CONFIG_HOME is not set - home, err := os.UserHomeDir() - if err == nil { - xdgConfigHome = filepath.Join(home, ".config") - } - } - - if xdgConfigHome != "" { - userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands") - userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix) - if err != nil { - // Log error but continue - we'll still try to load other commands - fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err) - } else { - commands = append(commands, userCommands...) - } - } - - // Load commands from $HOME/.opencode/commands - home, err := os.UserHomeDir() - if err == nil { - homeCommandsDir := filepath.Join(home, ".opencode", "commands") - homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix) - if err != nil { - // Log error but continue - we'll still try to load other commands - fmt.Printf("Warning: failed to load home commands: %v\n", err) - } else { - commands = append(commands, homeCommands...) - } - } - - // Load project commands from data directory - projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands") - projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix) - if err != nil { - // Log error but return what we have so far - fmt.Printf("Warning: failed to load project commands: %v\n", err) - } else { - commands = append(commands, projectCommands...) - } - - return commands, nil -} - -// loadCommandsFromDir loads commands from a specific directory with the given prefix -func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) { - // Check if the commands directory exists - if _, err := os.Stat(commandsDir); os.IsNotExist(err) { - // Create the commands directory if it doesn't exist - if err := os.MkdirAll(commandsDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err) - } - // Return empty list since we just created the directory - return []Command{}, nil - } - - var commands []Command - - // Walk through the commands directory and load all .md files - err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - // Skip directories - if info.IsDir() { - return nil - } - - // Only process markdown files - if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") { - return nil - } - - // Read the file content - content, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read command file %s: %w", path, err) - } - - // Get the command ID from the file name without the .md extension - commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name())) - - // Get relative path from commands directory - relPath, err := filepath.Rel(commandsDir, path) - if err != nil { - return fmt.Errorf("failed to get relative path for %s: %w", path, err) - } - - // Create the command ID from the relative path - // Replace directory separators with colons - commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":") - if commandIDPath != "." { - commandID = commandIDPath + ":" + commandID - } - - // Create a command - command := Command{ - ID: prefix + commandID, - Title: prefix + commandID, - Description: fmt.Sprintf("Custom command from %s", relPath), - Handler: func(cmd Command) tea.Cmd { - commandContent := string(content) - - // Check for named arguments - matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1) - if len(matches) > 0 { - // Extract unique argument names - argNames := make([]string, 0) - argMap := make(map[string]bool) - - for _, match := range matches { - argName := match[1] // Group 1 is the name without $ - if !argMap[argName] { - argMap[argName] = true - argNames = append(argNames, argName) - } - } - - // Show multi-arguments dialog for all named arguments - return util.CmdHandler(ShowMultiArgumentsDialogMsg{ - CommandID: cmd.ID, - Content: commandContent, - ArgNames: argNames, - }) - } - - // No arguments needed, run command directly - return util.CmdHandler(CommandRunCustomMsg{ - Content: commandContent, - Args: nil, // No arguments - }) - }, - } - - commands = append(commands, command) - return nil - }) - - if err != nil { - return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err) - } - - return commands, nil -} - -// CommandRunCustomMsg is sent when a custom command is executed -type CommandRunCustomMsg struct { - Content string - Args map[string]string // Map of argument names to values -} diff --git a/internal/tui/components/dialog/custom_commands_test.go b/internal/tui/components/dialog/custom_commands_test.go deleted file mode 100644 index 3468ac3b0b2c5acc8999fcf3b444411e7f07ca5c..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/custom_commands_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package dialog - -import ( - "testing" - "regexp" -) - -func TestNamedArgPattern(t *testing.T) { - testCases := []struct { - input string - expected []string - }{ - { - input: "This is a test with $ARGUMENTS placeholder", - expected: []string{"ARGUMENTS"}, - }, - { - input: "This is a test with $FOO and $BAR placeholders", - expected: []string{"FOO", "BAR"}, - }, - { - input: "This is a test with $FOO_BAR and $BAZ123 placeholders", - expected: []string{"FOO_BAR", "BAZ123"}, - }, - { - input: "This is a test with no placeholders", - expected: []string{}, - }, - { - input: "This is a test with $FOO appearing twice: $FOO", - expected: []string{"FOO"}, - }, - { - input: "This is a test with $1INVALID placeholder", - expected: []string{}, - }, - } - - for _, tc := range testCases { - matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1) - - // Extract unique argument names - argNames := make([]string, 0) - argMap := make(map[string]bool) - - for _, match := range matches { - argName := match[1] // Group 1 is the name without $ - if !argMap[argName] { - argMap[argName] = true - argNames = append(argNames, argName) - } - } - - // Check if we got the expected number of arguments - if len(argNames) != len(tc.expected) { - t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input) - continue - } - - // Check if we got the expected argument names - for _, expectedArg := range tc.expected { - found := false - for _, actualArg := range argNames { - if actualArg == expectedArg { - found = true - break - } - } - if !found { - t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input) - } - } - } -} - -func TestRegexPattern(t *testing.T) { - pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) - - validMatches := []string{ - "$FOO", - "$BAR", - "$FOO_BAR", - "$BAZ123", - "$ARGUMENTS", - } - - invalidMatches := []string{ - "$foo", - "$1BAR", - "$_FOO", - "FOO", - "$", - } - - for _, valid := range validMatches { - if !pattern.MatchString(valid) { - t.Errorf("Expected %s to match, but it didn't", valid) - } - } - - for _, invalid := range invalidMatches { - if pattern.MatchString(invalid) { - t.Errorf("Expected %s not to match, but it did", invalid) - } - } -} \ No newline at end of file diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go deleted file mode 100644 index 3b9a0dc6c39a090e084b010a4ac5640f338a4836..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/filepicker.go +++ /dev/null @@ -1,471 +0,0 @@ -package dialog - -import ( - "fmt" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/tui/image" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -const ( - maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB - downArrow = "down" - upArrow = "up" -) - -type FilePrickerKeyMap struct { - Enter key.Binding - Down key.Binding - Up key.Binding - Forward key.Binding - Backward key.Binding - OpenFilePicker key.Binding - Esc key.Binding - InsertCWD key.Binding -} - -var filePickerKeyMap = FilePrickerKeyMap{ - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select file/enter directory"), - ), - Down: key.NewBinding( - key.WithKeys("j", downArrow), - key.WithHelp("↓/j", "down"), - ), - Up: key.NewBinding( - key.WithKeys("k", upArrow), - key.WithHelp("↑/k", "up"), - ), - Forward: key.NewBinding( - key.WithKeys("l"), - key.WithHelp("l", "enter directory"), - ), - Backward: key.NewBinding( - key.WithKeys("h", "backspace"), - key.WithHelp("h/backspace", "go back"), - ), - OpenFilePicker: key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "open file picker"), - ), - Esc: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close/exit"), - ), - InsertCWD: key.NewBinding( - key.WithKeys("i"), - key.WithHelp("i", "manual path input"), - ), -} - -type filepickerCmp struct { - basePath string - width int - height int - cursor int - err error - cursorChain stack - viewport viewport.Model - dirs []os.DirEntry - cwdDetails *DirNode - selectedFile string - cwd textinput.Model - ShowFilePicker bool - app *app.App -} - -type DirNode struct { - parent *DirNode - child *DirNode - directory string -} -type stack []int - -func (s stack) Push(v int) stack { - return append(s, v) -} - -func (s stack) Pop() (stack, int) { - l := len(s) - return s[:l-1], s[l-1] -} - -type AttachmentAddedMsg struct { - Attachment message.Attachment -} - -func (f *filepickerCmp) Init() tea.Cmd { - return nil -} - -func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - f.width = 60 - f.height = 20 - f.viewport.Width = 80 - f.viewport.Height = 22 - f.cursor = 0 - f.getCurrentFileBelowCursor() - case tea.KeyMsg: - if f.cwd.Focused() { - f.cwd, cmd = f.cwd.Update(msg) - } - switch { - case key.Matches(msg, filePickerKeyMap.InsertCWD): - f.cwd.Focus() - return f, cmd - case key.Matches(msg, filePickerKeyMap.Esc): - if f.cwd.Focused() { - f.cwd.Blur() - } - case key.Matches(msg, filePickerKeyMap.Down): - if !f.cwd.Focused() || msg.String() == downArrow { - if f.cursor < len(f.dirs)-1 { - f.cursor++ - f.getCurrentFileBelowCursor() - } - } - case key.Matches(msg, filePickerKeyMap.Up): - if !f.cwd.Focused() || msg.String() == upArrow { - if f.cursor > 0 { - f.cursor-- - f.getCurrentFileBelowCursor() - } - } - case key.Matches(msg, filePickerKeyMap.Enter): - var path string - var isPathDir bool - if f.cwd.Focused() { - path = f.cwd.Value() - fileInfo, err := os.Stat(path) - if err != nil { - logging.ErrorPersist("Invalid path") - return f, cmd - } - isPathDir = fileInfo.IsDir() - } else { - path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name()) - isPathDir = f.dirs[f.cursor].IsDir() - } - if isPathDir { - newWorkingDir := DirNode{parent: f.cwdDetails, directory: path} - f.cwdDetails.child = &newWorkingDir - f.cwdDetails = f.cwdDetails.child - f.cursorChain = f.cursorChain.Push(f.cursor) - f.dirs = readDir(f.cwdDetails.directory, false) - f.cursor = 0 - f.cwd.SetValue(f.cwdDetails.directory) - f.getCurrentFileBelowCursor() - } else { - f.selectedFile = path - return f.addAttachmentToMessage() - } - case key.Matches(msg, filePickerKeyMap.Esc): - if !f.cwd.Focused() { - f.cursorChain = make(stack, 0) - f.cursor = 0 - } else { - f.cwd.Blur() - } - case key.Matches(msg, filePickerKeyMap.Forward): - if !f.cwd.Focused() { - if f.dirs[f.cursor].IsDir() { - path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name()) - newWorkingDir := DirNode{parent: f.cwdDetails, directory: path} - f.cwdDetails.child = &newWorkingDir - f.cwdDetails = f.cwdDetails.child - f.cursorChain = f.cursorChain.Push(f.cursor) - f.dirs = readDir(f.cwdDetails.directory, false) - f.cursor = 0 - f.cwd.SetValue(f.cwdDetails.directory) - f.getCurrentFileBelowCursor() - } - } - case key.Matches(msg, filePickerKeyMap.Backward): - if !f.cwd.Focused() { - if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil { - f.cursorChain, f.cursor = f.cursorChain.Pop() - f.cwdDetails = f.cwdDetails.parent - f.cwdDetails.child = nil - f.dirs = readDir(f.cwdDetails.directory, false) - f.cwd.SetValue(f.cwdDetails.directory) - f.getCurrentFileBelowCursor() - } - } - case key.Matches(msg, filePickerKeyMap.OpenFilePicker): - f.dirs = readDir(f.cwdDetails.directory, false) - f.cursor = 0 - f.getCurrentFileBelowCursor() - } - } - return f, cmd -} - -func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) { - modeInfo := GetSelectedModel(config.Get()) - if !modeInfo.SupportsAttachments { - logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name)) - return f, nil - } - - selectedFilePath := f.selectedFile - if !isExtSupported(selectedFilePath) { - logging.ErrorPersist("Unsupported file") - return f, nil - } - - isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize) - if err != nil { - logging.ErrorPersist("unable to read the image") - return f, nil - } - if isFileLarge { - logging.ErrorPersist("file too large, max 5MB") - return f, nil - } - - content, err := os.ReadFile(selectedFilePath) - if err != nil { - logging.ErrorPersist("Unable read selected file") - return f, nil - } - - mimeBufferSize := min(512, len(content)) - mimeType := http.DetectContentType(content[:mimeBufferSize]) - fileName := filepath.Base(selectedFilePath) - attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content} - f.selectedFile = "" - return f, util.CmdHandler(AttachmentAddedMsg{attachment}) -} - -func (f *filepickerCmp) View() string { - t := theme.CurrentTheme() - const maxVisibleDirs = 20 - const maxWidth = 80 - - adjustedWidth := maxWidth - for _, file := range f.dirs { - if len(file.Name()) > adjustedWidth-4 { // Account for padding - adjustedWidth = len(file.Name()) + 4 - } - } - adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1 - - files := make([]string, 0, maxVisibleDirs) - startIdx := 0 - - if len(f.dirs) > maxVisibleDirs { - halfVisible := maxVisibleDirs / 2 - if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible { - startIdx = f.cursor - halfVisible - } else if f.cursor >= len(f.dirs)-halfVisible { - startIdx = len(f.dirs) - maxVisibleDirs - } - } - - endIdx := min(startIdx+maxVisibleDirs, len(f.dirs)) - - for i := startIdx; i < endIdx; i++ { - file := f.dirs[i] - itemStyle := styles.BaseStyle().Width(adjustedWidth) - - if i == f.cursor { - itemStyle = itemStyle. - Background(t.Primary()). - Foreground(t.Background()). - Bold(true) - } - filename := file.Name() - - if len(filename) > adjustedWidth-4 { - filename = filename[:adjustedWidth-7] + "..." - } - if file.IsDir() { - filename = filename + "/" - } - // No need to reassign filename if it's not changing - - files = append(files, itemStyle.Padding(0, 1).Render(filename)) - } - - // Pad to always show exactly 21 lines - for len(files) < maxVisibleDirs { - files = append(files, styles.BaseStyle().Width(adjustedWidth).Render("")) - } - - currentPath := styles.BaseStyle(). - Height(1). - Width(adjustedWidth). - Render(f.cwd.View()) - - viewportstyle := lipgloss.NewStyle(). - Width(f.viewport.Width). - Background(t.Background()). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.TextMuted()). - BorderBackground(t.Background()). - Padding(2). - Render(f.viewport.View()) - var insertExitText string - if f.IsCWDFocused() { - insertExitText = "Press esc to exit typing path" - } else { - insertExitText = "Press i to start typing path" - } - - content := lipgloss.JoinVertical( - lipgloss.Left, - currentPath, - styles.BaseStyle().Width(adjustedWidth).Render(""), - styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)), - styles.BaseStyle().Width(adjustedWidth).Render(""), - styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText), - ) - - f.cwd.SetValue(f.cwd.Value()) - contentStyle := styles.BaseStyle().Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4) - - return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle) -} - -type FilepickerCmp interface { - tea.Model - ToggleFilepicker(showFilepicker bool) - IsCWDFocused() bool -} - -func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) { - f.ShowFilePicker = showFilepicker -} - -func (f *filepickerCmp) IsCWDFocused() bool { - return f.cwd.Focused() -} - -func NewFilepickerCmp(app *app.App) FilepickerCmp { - homepath, err := os.UserHomeDir() - if err != nil { - logging.Error("error loading user files") - return nil - } - baseDir := DirNode{parent: nil, directory: homepath} - dirs := readDir(homepath, false) - viewport := viewport.New(0, 0) - currentDirectory := textinput.New() - currentDirectory.CharLimit = 200 - currentDirectory.Width = 44 - currentDirectory.Cursor.Blink = true - currentDirectory.SetValue(baseDir.directory) - return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app} -} - -func (f *filepickerCmp) getCurrentFileBelowCursor() { - if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) { - logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor)) - f.viewport.SetContent("Preview unavailable") - return - } - - dir := f.dirs[f.cursor] - filename := dir.Name() - if !dir.IsDir() && isExtSupported(filename) { - fullPath := f.cwdDetails.directory + "/" + dir.Name() - - go func() { - imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath) - if err != nil { - logging.Error(err.Error()) - f.viewport.SetContent("Preview unavailable") - return - } - - f.viewport.SetContent(imageString) - }() - } else { - f.viewport.SetContent("Preview unavailable") - } -} - -func readDir(path string, showHidden bool) []os.DirEntry { - logging.Info(fmt.Sprintf("Reading directory: %s", path)) - - entriesChan := make(chan []os.DirEntry, 1) - errChan := make(chan error, 1) - - go func() { - dirEntries, err := os.ReadDir(path) - if err != nil { - logging.ErrorPersist(err.Error()) - errChan <- err - return - } - entriesChan <- dirEntries - }() - - select { - case dirEntries := <-entriesChan: - sort.Slice(dirEntries, func(i, j int) bool { - if dirEntries[i].IsDir() == dirEntries[j].IsDir() { - return dirEntries[i].Name() < dirEntries[j].Name() - } - return dirEntries[i].IsDir() - }) - - if showHidden { - return dirEntries - } - - var sanitizedDirEntries []os.DirEntry - for _, dirEntry := range dirEntries { - isHidden, _ := IsHidden(dirEntry.Name()) - if !isHidden { - if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) { - sanitizedDirEntries = append(sanitizedDirEntries, dirEntry) - } - } - } - - return sanitizedDirEntries - - case err := <-errChan: - logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err) - return []os.DirEntry{} - - case <-time.After(5 * time.Second): - logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil) - return []os.DirEntry{} - } -} - -func IsHidden(file string) (bool, error) { - return strings.HasPrefix(file, "."), nil -} - -func isExtSupported(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) - return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png") -} diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go deleted file mode 100644 index 90959ad2ed540ac23e3285da0380c7b88635f708..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/help.go +++ /dev/null @@ -1,200 +0,0 @@ -package dialog - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" -) - -type helpCmp struct { - width int - height int - keys []key.Binding -} - -func (h *helpCmp) Init() tea.Cmd { - return nil -} - -func (h *helpCmp) SetBindings(k []key.Binding) { - h.keys = k -} - -func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - h.width = 90 - h.height = msg.Height - } - return h, nil -} - -func removeDuplicateBindings(bindings []key.Binding) []key.Binding { - seen := make(map[string]struct{}) - result := make([]key.Binding, 0, len(bindings)) - - // Process bindings in reverse order - for i := len(bindings) - 1; i >= 0; i-- { - b := bindings[i] - k := strings.Join(b.Keys(), " ") - if _, ok := seen[k]; ok { - // duplicate, skip - continue - } - seen[k] = struct{}{} - // Add to the beginning of result to maintain original order - result = append([]key.Binding{b}, result...) - } - - return result -} - -func (h *helpCmp) render() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - helpKeyStyle := styles.Bold(). - Background(t.Background()). - Foreground(t.Text()). - Padding(0, 1, 0, 0) - - helpDescStyle := styles.Regular(). - Background(t.Background()). - Foreground(t.TextMuted()) - - // Compile list of bindings to render - bindings := removeDuplicateBindings(h.keys) - - // Enumerate through each group of bindings, populating a series of - // pairs of columns, one for keys, one for descriptions - var ( - pairs []string - width int - rows = 12 - 2 - ) - - for i := 0; i < len(bindings); i += rows { - var ( - keys []string - descs []string - ) - for j := i; j < min(i+rows, len(bindings)); j++ { - keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key)) - descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc)) - } - - // Render pair of columns; beyond the first pair, render a three space - // left margin, in order to visually separate the pairs. - var cols []string - if len(pairs) > 0 { - cols = []string{baseStyle.Render(" ")} - } - - maxDescWidth := 0 - for _, desc := range descs { - if maxDescWidth < lipgloss.Width(desc) { - maxDescWidth = lipgloss.Width(desc) - } - } - for i := range descs { - remainingWidth := maxDescWidth - lipgloss.Width(descs[i]) - if remainingWidth > 0 { - descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth)) - } - } - maxKeyWidth := 0 - for _, key := range keys { - if maxKeyWidth < lipgloss.Width(key) { - maxKeyWidth = lipgloss.Width(key) - } - } - for i := range keys { - remainingWidth := maxKeyWidth - lipgloss.Width(keys[i]) - if remainingWidth > 0 { - keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth)) - } - } - - cols = append(cols, - strings.Join(keys, "\n"), - strings.Join(descs, "\n"), - ) - - pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...)) - // check whether it exceeds the maximum width avail (the width of the - // terminal, subtracting 2 for the borders). - width += lipgloss.Width(pair) - if width > h.width-2 { - break - } - pairs = append(pairs, pair) - } - - // https://github.com/charmbracelet/lipgloss/issues/209 - if len(pairs) > 1 { - prefix := pairs[:len(pairs)-1] - lastPair := pairs[len(pairs)-1] - prefix = append(prefix, lipgloss.Place( - lipgloss.Width(lastPair), // width - lipgloss.Height(prefix[0]), // height - lipgloss.Left, // x - lipgloss.Top, // y - lastPair, // content - lipgloss.WithWhitespaceBackground(t.Background()), - )) - content := baseStyle.Width(h.width).Render( - lipgloss.JoinHorizontal( - lipgloss.Top, - prefix..., - ), - ) - return content - } - - // Join pairs of columns and enclose in a border - content := baseStyle.Width(h.width).Render( - lipgloss.JoinHorizontal( - lipgloss.Top, - pairs..., - ), - ) - return content -} - -func (h *helpCmp) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - content := h.render() - header := baseStyle. - Bold(true). - Width(lipgloss.Width(content)). - Foreground(t.Primary()). - Render("Keyboard Shortcuts") - - return baseStyle.Padding(1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.TextMuted()). - Width(h.width). - BorderBackground(t.Background()). - Render( - lipgloss.JoinVertical(lipgloss.Center, - header, - baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))), - content, - ), - ) -} - -type HelpCmp interface { - tea.Model - SetBindings([]key.Binding) -} - -func NewHelpCmp() HelpCmp { - return &helpCmp{} -} diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go deleted file mode 100644 index 77c76584d9bf1abaceb2206c3f577c8834c3cc04..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/init.go +++ /dev/null @@ -1,189 +0,0 @@ -package dialog - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// InitDialogCmp is a component that asks the user if they want to initialize the project. -type InitDialogCmp struct { - width, height int - selected int - keys initDialogKeyMap -} - -// NewInitDialogCmp creates a new InitDialogCmp. -func NewInitDialogCmp() InitDialogCmp { - return InitDialogCmp{ - selected: 0, - keys: initDialogKeyMap{}, - } -} - -type initDialogKeyMap struct { - Tab key.Binding - Left key.Binding - Right key.Binding - Enter key.Binding - Escape key.Binding - Y key.Binding - N key.Binding -} - -// ShortHelp implements key.Map. -func (k initDialogKeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - key.NewBinding( - key.WithKeys("tab", "left", "right"), - key.WithHelp("tab/←/→", "toggle selection"), - ), - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm"), - ), - key.NewBinding( - key.WithKeys("esc", "q"), - key.WithHelp("esc/q", "cancel"), - ), - key.NewBinding( - key.WithKeys("y", "n"), - key.WithHelp("y/n", "yes/no"), - ), - } -} - -// FullHelp implements key.Map. -func (k initDialogKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{k.ShortHelp()} -} - -// Init implements tea.Model. -func (m InitDialogCmp) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): - return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false}) - case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))): - m.selected = (m.selected + 1) % 2 - return m, nil - case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): - return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0}) - case key.Matches(msg, key.NewBinding(key.WithKeys("y"))): - return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true}) - case key.Matches(msg, key.NewBinding(key.WithKeys("n"))): - return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false}) - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - } - return m, nil -} - -// View implements tea.Model. -func (m InitDialogCmp) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - // Calculate width needed for content - maxWidth := 60 // Width for explanation text - - title := baseStyle. - Foreground(t.Primary()). - Bold(true). - Width(maxWidth). - Padding(0, 1). - Render("Initialize Project") - - explanation := baseStyle. - Foreground(t.Text()). - Width(maxWidth). - Padding(0, 1). - Render("Initialization generates a new OpenCode.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.") - - question := baseStyle. - Foreground(t.Text()). - Width(maxWidth). - Padding(1, 1). - Render("Would you like to initialize this project?") - - maxWidth = min(maxWidth, m.width-10) - yesStyle := baseStyle - noStyle := baseStyle - - if m.selected == 0 { - yesStyle = yesStyle. - Background(t.Primary()). - Foreground(t.Background()). - Bold(true) - noStyle = noStyle. - Background(t.Background()). - Foreground(t.Primary()) - } else { - noStyle = noStyle. - Background(t.Primary()). - Foreground(t.Background()). - Bold(true) - yesStyle = yesStyle. - Background(t.Background()). - Foreground(t.Primary()) - } - - yes := yesStyle.Padding(0, 3).Render("Yes") - no := noStyle.Padding(0, 3).Render("No") - - buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no) - buttons = baseStyle. - Width(maxWidth). - Padding(1, 0). - Render(buttons) - - content := lipgloss.JoinVertical( - lipgloss.Left, - title, - baseStyle.Width(maxWidth).Render(""), - explanation, - question, - buttons, - baseStyle.Width(maxWidth).Render(""), - ) - - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) -} - -// SetSize sets the size of the component. -func (m *InitDialogCmp) SetSize(width, height int) { - m.width = width - m.height = height -} - -// Bindings implements layout.Bindings. -func (m InitDialogCmp) Bindings() []key.Binding { - return m.keys.ShortHelp() -} - -// CloseInitDialogMsg is a message that is sent when the init dialog is closed. -type CloseInitDialogMsg struct { - Initialize bool -} - -// ShowInitDialogMsg is a message that is sent to show the init dialog. -type ShowInitDialogMsg struct { - Show bool -} diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go deleted file mode 100644 index 77c2a02ac1979d7ad5fe0c8f4845f73f6ea36e81..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/models.go +++ /dev/null @@ -1,373 +0,0 @@ -package dialog - -import ( - "fmt" - "slices" - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -const ( - numVisibleModels = 10 - maxDialogWidth = 40 -) - -// ModelSelectedMsg is sent when a model is selected -type ModelSelectedMsg struct { - Model models.Model -} - -// CloseModelDialogMsg is sent when a model is selected -type CloseModelDialogMsg struct{} - -// ModelDialog interface for the model selection dialog -type ModelDialog interface { - tea.Model - layout.Bindings -} - -type modelDialogCmp struct { - models []models.Model - provider models.ModelProvider - availableProviders []models.ModelProvider - - selectedIdx int - width int - height int - scrollOffset int - hScrollOffset int - hScrollPossible bool -} - -type modelKeyMap struct { - Up key.Binding - Down key.Binding - Left key.Binding - Right key.Binding - Enter key.Binding - Escape key.Binding - J key.Binding - K key.Binding - H key.Binding - L key.Binding -} - -var modelKeys = modelKeyMap{ - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "previous model"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "next model"), - ), - Left: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("←", "scroll left"), - ), - Right: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("→", "scroll right"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select model"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), - ), - J: key.NewBinding( - key.WithKeys("j"), - key.WithHelp("j", "next model"), - ), - K: key.NewBinding( - key.WithKeys("k"), - key.WithHelp("k", "previous model"), - ), - H: key.NewBinding( - key.WithKeys("h"), - key.WithHelp("h", "scroll left"), - ), - L: key.NewBinding( - key.WithKeys("l"), - key.WithHelp("l", "scroll right"), - ), -} - -func (m *modelDialogCmp) Init() tea.Cmd { - m.setupModels() - return nil -} - -func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K): - m.moveSelectionUp() - case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J): - m.moveSelectionDown() - case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H): - if m.hScrollPossible { - m.switchProvider(-1) - } - case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L): - if m.hScrollPossible { - m.switchProvider(1) - } - case key.Matches(msg, modelKeys.Enter): - util.ReportInfo(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name)) - return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]}) - case key.Matches(msg, modelKeys.Escape): - return m, util.CmdHandler(CloseModelDialogMsg{}) - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - } - - return m, nil -} - -// moveSelectionUp moves the selection up or wraps to bottom -func (m *modelDialogCmp) moveSelectionUp() { - if m.selectedIdx > 0 { - m.selectedIdx-- - } else { - m.selectedIdx = len(m.models) - 1 - m.scrollOffset = max(0, len(m.models)-numVisibleModels) - } - - // Keep selection visible - if m.selectedIdx < m.scrollOffset { - m.scrollOffset = m.selectedIdx - } -} - -// moveSelectionDown moves the selection down or wraps to top -func (m *modelDialogCmp) moveSelectionDown() { - if m.selectedIdx < len(m.models)-1 { - m.selectedIdx++ - } else { - m.selectedIdx = 0 - m.scrollOffset = 0 - } - - // Keep selection visible - if m.selectedIdx >= m.scrollOffset+numVisibleModels { - m.scrollOffset = m.selectedIdx - (numVisibleModels - 1) - } -} - -func (m *modelDialogCmp) switchProvider(offset int) { - newOffset := m.hScrollOffset + offset - - // Ensure we stay within bounds - if newOffset < 0 { - newOffset = len(m.availableProviders) - 1 - } - if newOffset >= len(m.availableProviders) { - newOffset = 0 - } - - m.hScrollOffset = newOffset - m.provider = m.availableProviders[m.hScrollOffset] - m.setupModelsForProvider(m.provider) -} - -func (m *modelDialogCmp) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - // Capitalize first letter of provider name - providerName := strings.ToUpper(string(m.provider)[:1]) + string(m.provider[1:]) - title := baseStyle. - Foreground(t.Primary()). - Bold(true). - Width(maxDialogWidth). - Padding(0, 0, 1). - Render(fmt.Sprintf("Select %s Model", providerName)) - - // Render visible models - endIdx := min(m.scrollOffset+numVisibleModels, len(m.models)) - modelItems := make([]string, 0, endIdx-m.scrollOffset) - - for i := m.scrollOffset; i < endIdx; i++ { - itemStyle := baseStyle.Width(maxDialogWidth) - if i == m.selectedIdx { - itemStyle = itemStyle.Background(t.Primary()). - Foreground(t.Background()).Bold(true) - } - modelItems = append(modelItems, itemStyle.Render(m.models[i].Name)) - } - - scrollIndicator := m.getScrollIndicators(maxDialogWidth) - - content := lipgloss.JoinVertical( - lipgloss.Left, - title, - baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)), - scrollIndicator, - ) - - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) -} - -func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string { - var indicator string - - if len(m.models) > numVisibleModels { - if m.scrollOffset > 0 { - indicator += "↑ " - } - if m.scrollOffset+numVisibleModels < len(m.models) { - indicator += "↓ " - } - } - - if m.hScrollPossible { - if m.hScrollOffset > 0 { - indicator = "← " + indicator - } - if m.hScrollOffset < len(m.availableProviders)-1 { - indicator += "→" - } - } - - if indicator == "" { - return "" - } - - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - return baseStyle. - Foreground(t.Primary()). - Width(maxWidth). - Align(lipgloss.Right). - Bold(true). - Render(indicator) -} - -func (m *modelDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(modelKeys) -} - -func (m *modelDialogCmp) setupModels() { - cfg := config.Get() - modelInfo := GetSelectedModel(cfg) - m.availableProviders = getEnabledProviders(cfg) - m.hScrollPossible = len(m.availableProviders) > 1 - - m.provider = modelInfo.Provider - m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider) - - m.setupModelsForProvider(m.provider) -} - -func GetSelectedModel(cfg *config.Config) models.Model { - - agentCfg := cfg.Agents[config.AgentCoder] - selectedModelId := agentCfg.Model - return models.SupportedModels[selectedModelId] -} - -func getEnabledProviders(cfg *config.Config) []models.ModelProvider { - var providers []models.ModelProvider - for providerId, provider := range cfg.Providers { - if !provider.Disabled { - providers = append(providers, providerId) - } - } - - // Sort by provider popularity - slices.SortFunc(providers, func(a, b models.ModelProvider) int { - rA := models.ProviderPopularity[a] - rB := models.ProviderPopularity[b] - - // models not included in popularity ranking default to last - if rA == 0 { - rA = 999 - } - if rB == 0 { - rB = 999 - } - return rA - rB - }) - return providers -} - -// findProviderIndex returns the index of the provider in the list, or -1 if not found -func findProviderIndex(providers []models.ModelProvider, provider models.ModelProvider) int { - for i, p := range providers { - if p == provider { - return i - } - } - return -1 -} - -func (m *modelDialogCmp) setupModelsForProvider(provider models.ModelProvider) { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - selectedModelId := agentCfg.Model - - m.provider = provider - m.models = getModelsForProvider(provider) - m.selectedIdx = 0 - m.scrollOffset = 0 - - // Try to select the current model if it belongs to this provider - if provider == models.SupportedModels[selectedModelId].Provider { - for i, model := range m.models { - if model.ID == selectedModelId { - m.selectedIdx = i - // Adjust scroll position to keep selected model visible - if m.selectedIdx >= numVisibleModels { - m.scrollOffset = m.selectedIdx - (numVisibleModels - 1) - } - break - } - } - } -} - -func getModelsForProvider(provider models.ModelProvider) []models.Model { - var providerModels []models.Model - for _, model := range models.SupportedModels { - if model.Provider == provider { - providerModels = append(providerModels, model) - } - } - - // reverse alphabetical order (if llm naming was consistent latest would appear first) - slices.SortFunc(providerModels, func(a, b models.Model) int { - if a.Name > b.Name { - return -1 - } else if a.Name < b.Name { - return 1 - } - return 0 - }) - - return providerModels -} - -func NewModelDialogCmp() ModelDialog { - return &modelDialogCmp{} -} diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go deleted file mode 100644 index 6c135098a7ade938e7bec1a69b4a3a6f5db79d6f..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/permission.go +++ /dev/null @@ -1,522 +0,0 @@ -package dialog - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/diff" - "github.com/opencode-ai/opencode/internal/llm/tools" - "github.com/opencode-ai/opencode/internal/permission" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -type PermissionAction string - -// Permission responses -const ( - PermissionAllow PermissionAction = "allow" - PermissionAllowForSession PermissionAction = "allow_session" - PermissionDeny PermissionAction = "deny" -) - -// PermissionResponseMsg represents the user's response to a permission request -type PermissionResponseMsg struct { - Permission permission.PermissionRequest - Action PermissionAction -} - -// PermissionDialogCmp interface for permission dialog component -type PermissionDialogCmp interface { - tea.Model - layout.Bindings - SetPermissions(permission permission.PermissionRequest) tea.Cmd -} - -type permissionsMapping struct { - Left key.Binding - Right key.Binding - EnterSpace key.Binding - Allow key.Binding - AllowSession key.Binding - Deny key.Binding - Tab key.Binding -} - -var permissionsKeys = permissionsMapping{ - Left: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("←", "switch options"), - ), - Right: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("→", "switch options"), - ), - EnterSpace: key.NewBinding( - key.WithKeys("enter", " "), - key.WithHelp("enter/space", "confirm"), - ), - Allow: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "allow"), - ), - AllowSession: key.NewBinding( - key.WithKeys("s"), - key.WithHelp("s", "allow for session"), - ), - Deny: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "deny"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch options"), - ), -} - -// permissionDialogCmp is the implementation of PermissionDialog -type permissionDialogCmp struct { - width int - height int - permission permission.PermissionRequest - windowSize tea.WindowSizeMsg - contentViewPort viewport.Model - selectedOption int // 0: Allow, 1: Allow for session, 2: Deny - - diffCache map[string]string - markdownCache map[string]string -} - -func (p *permissionDialogCmp) Init() tea.Cmd { - return p.contentViewPort.Init() -} - -func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.windowSize = msg - cmd := p.SetSize() - cmds = append(cmds, cmd) - p.markdownCache = make(map[string]string) - p.diffCache = make(map[string]string) - case tea.KeyMsg: - switch { - case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab): - p.selectedOption = (p.selectedOption + 1) % 3 - return p, nil - case key.Matches(msg, permissionsKeys.Left): - p.selectedOption = (p.selectedOption + 2) % 3 - case key.Matches(msg, permissionsKeys.EnterSpace): - return p, p.selectCurrentOption() - case key.Matches(msg, permissionsKeys.Allow): - return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}) - case key.Matches(msg, permissionsKeys.AllowSession): - return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}) - case key.Matches(msg, permissionsKeys.Deny): - return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}) - default: - // Pass other keys to viewport - viewPort, cmd := p.contentViewPort.Update(msg) - p.contentViewPort = viewPort - cmds = append(cmds, cmd) - } - } - - return p, tea.Batch(cmds...) -} - -func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd { - var action PermissionAction - - switch p.selectedOption { - case 0: - action = PermissionAllow - case 1: - action = PermissionAllowForSession - case 2: - action = PermissionDeny - } - - return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}) -} - -func (p *permissionDialogCmp) renderButtons() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - allowStyle := baseStyle - allowSessionStyle := baseStyle - denyStyle := baseStyle - spacerStyle := baseStyle.Background(t.Background()) - - // Style the selected button - switch p.selectedOption { - case 0: - allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background()) - allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary()) - denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary()) - case 1: - allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary()) - allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background()) - denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary()) - case 2: - allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary()) - allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary()) - denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background()) - } - - allowButton := allowStyle.Padding(0, 1).Render("Allow (a)") - allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)") - denyButton := denyStyle.Padding(0, 1).Render("Deny (d)") - - content := lipgloss.JoinHorizontal( - lipgloss.Left, - allowButton, - spacerStyle.Render(" "), - allowSessionButton, - spacerStyle.Render(" "), - denyButton, - spacerStyle.Render(" "), - ) - - remainingWidth := p.width - lipgloss.Width(content) - if remainingWidth > 0 { - content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content - } - return content -} - -func (p *permissionDialogCmp) renderHeader() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool") - toolValue := baseStyle. - Foreground(t.Text()). - Width(p.width - lipgloss.Width(toolKey)). - Render(fmt.Sprintf(": %s", p.permission.ToolName)) - - pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path") - pathValue := baseStyle. - Foreground(t.Text()). - Width(p.width - lipgloss.Width(pathKey)). - Render(fmt.Sprintf(": %s", p.permission.Path)) - - headerParts := []string{ - lipgloss.JoinHorizontal( - lipgloss.Left, - toolKey, - toolValue, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - lipgloss.JoinHorizontal( - lipgloss.Left, - pathKey, - pathValue, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - } - - // Add tool-specific header information - switch p.permission.ToolName { - case tools.BashToolName: - headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command")) - case tools.EditToolName: - params := p.permission.Params.(tools.EditPermissionsParams) - fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File") - filePath := baseStyle. - Foreground(t.Text()). - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(": %s", params.FilePath)) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - - case tools.WriteToolName: - params := p.permission.Params.(tools.WritePermissionsParams) - fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File") - filePath := baseStyle. - Foreground(t.Text()). - Width(p.width - lipgloss.Width(fileKey)). - Render(fmt.Sprintf(": %s", params.FilePath)) - headerParts = append(headerParts, - lipgloss.JoinHorizontal( - lipgloss.Left, - fileKey, - filePath, - ), - baseStyle.Render(strings.Repeat(" ", p.width)), - ) - case tools.FetchToolName: - headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL")) - } - - return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...)) -} - -func (p *permissionDialogCmp) renderBashContent() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok { - content := fmt.Sprintf("```bash\n%s\n```", pr.Command) - - // Use the cache for markdown rendering - renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { - r := styles.GetMarkdownRenderer(p.width - 10) - s, err := r.Render(content) - return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err - }) - - finalContent := baseStyle. - Width(p.contentViewPort.Width). - Render(renderedContent) - p.contentViewPort.SetContent(finalContent) - return p.styleViewport() - } - return "" -} - -func (p *permissionDialogCmp) renderEditContent() string { - if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok { - diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) { - return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width)) - }) - - p.contentViewPort.SetContent(diff) - return p.styleViewport() - } - return "" -} - -func (p *permissionDialogCmp) renderPatchContent() string { - if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok { - diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) { - return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width)) - }) - - p.contentViewPort.SetContent(diff) - return p.styleViewport() - } - return "" -} - -func (p *permissionDialogCmp) renderWriteContent() string { - if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok { - // Use the cache for diff rendering - diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) { - return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width)) - }) - - p.contentViewPort.SetContent(diff) - return p.styleViewport() - } - return "" -} - -func (p *permissionDialogCmp) renderFetchContent() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok { - content := fmt.Sprintf("```bash\n%s\n```", pr.URL) - - // Use the cache for markdown rendering - renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { - r := styles.GetMarkdownRenderer(p.width - 10) - s, err := r.Render(content) - return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err - }) - - finalContent := baseStyle. - Width(p.contentViewPort.Width). - Render(renderedContent) - p.contentViewPort.SetContent(finalContent) - return p.styleViewport() - } - return "" -} - -func (p *permissionDialogCmp) renderDefaultContent() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - content := p.permission.Description - - // Use the cache for markdown rendering - renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { - r := styles.GetMarkdownRenderer(p.width - 10) - s, err := r.Render(content) - return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err - }) - - finalContent := baseStyle. - Width(p.contentViewPort.Width). - Render(renderedContent) - p.contentViewPort.SetContent(finalContent) - - if renderedContent == "" { - return "" - } - - return p.styleViewport() -} - -func (p *permissionDialogCmp) styleViewport() string { - t := theme.CurrentTheme() - contentStyle := lipgloss.NewStyle(). - Background(t.Background()) - - return contentStyle.Render(p.contentViewPort.View()) -} - -func (p *permissionDialogCmp) render() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - title := baseStyle. - Bold(true). - Width(p.width - 4). - Foreground(t.Primary()). - Render("Permission Required") - // Render header - headerContent := p.renderHeader() - // Render buttons - buttons := p.renderButtons() - - // Calculate content height dynamically based on window size - p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title) - p.contentViewPort.Width = p.width - 4 - - // Render content based on tool type - var contentFinal string - switch p.permission.ToolName { - case tools.BashToolName: - contentFinal = p.renderBashContent() - case tools.EditToolName: - contentFinal = p.renderEditContent() - case tools.PatchToolName: - contentFinal = p.renderPatchContent() - case tools.WriteToolName: - contentFinal = p.renderWriteContent() - case tools.FetchToolName: - contentFinal = p.renderFetchContent() - default: - contentFinal = p.renderDefaultContent() - } - - content := lipgloss.JoinVertical( - lipgloss.Top, - title, - baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))), - headerContent, - contentFinal, - buttons, - baseStyle.Render(strings.Repeat(" ", p.width-4)), - ) - - return baseStyle. - Padding(1, 0, 0, 1). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(p.width). - Height(p.height). - Render( - content, - ) -} - -func (p *permissionDialogCmp) View() string { - return p.render() -} - -func (p *permissionDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(permissionsKeys) -} - -func (p *permissionDialogCmp) SetSize() tea.Cmd { - if p.permission.ID == "" { - return nil - } - switch p.permission.ToolName { - case tools.BashToolName: - p.width = int(float64(p.windowSize.Width) * 0.4) - p.height = int(float64(p.windowSize.Height) * 0.3) - case tools.EditToolName: - p.width = int(float64(p.windowSize.Width) * 0.8) - p.height = int(float64(p.windowSize.Height) * 0.8) - case tools.WriteToolName: - p.width = int(float64(p.windowSize.Width) * 0.8) - p.height = int(float64(p.windowSize.Height) * 0.8) - case tools.FetchToolName: - p.width = int(float64(p.windowSize.Width) * 0.4) - p.height = int(float64(p.windowSize.Height) * 0.3) - default: - p.width = int(float64(p.windowSize.Width) * 0.7) - p.height = int(float64(p.windowSize.Height) * 0.5) - } - return nil -} - -func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd { - p.permission = permission - return p.SetSize() -} - -// Helper to get or set cached diff content -func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string { - if cached, ok := c.diffCache[key]; ok { - return cached - } - - content, err := generator() - if err != nil { - return fmt.Sprintf("Error formatting diff: %v", err) - } - - c.diffCache[key] = content - - return content -} - -// Helper to get or set cached markdown content -func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string { - if cached, ok := c.markdownCache[key]; ok { - return cached - } - - content, err := generator() - if err != nil { - return fmt.Sprintf("Error rendering markdown: %v", err) - } - - c.markdownCache[key] = content - - return content -} - -func NewPermissionDialogCmp() PermissionDialogCmp { - // Create viewport for content - contentViewport := viewport.New(0, 0) - - return &permissionDialogCmp{ - contentViewPort: contentViewport, - selectedOption: 0, // Default to "Allow" - diffCache: make(map[string]string), - markdownCache: make(map[string]string), - } -} diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go deleted file mode 100644 index f755fa272547657c84a40125421d9e7411a1530e..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/quit.go +++ /dev/null @@ -1,136 +0,0 @@ -package dialog - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -const question = "Are you sure you want to quit?" - -type CloseQuitMsg struct{} - -type QuitDialog interface { - tea.Model - layout.Bindings -} - -type quitDialogCmp struct { - selectedNo bool -} - -type helpMapping struct { - LeftRight key.Binding - EnterSpace key.Binding - Yes key.Binding - No key.Binding - Tab key.Binding -} - -var helpKeys = helpMapping{ - LeftRight: key.NewBinding( - key.WithKeys("left", "right"), - key.WithHelp("←/→", "switch options"), - ), - EnterSpace: key.NewBinding( - key.WithKeys("enter", " "), - key.WithHelp("enter/space", "confirm"), - ), - Yes: key.NewBinding( - key.WithKeys("y", "Y"), - key.WithHelp("y/Y", "yes"), - ), - No: key.NewBinding( - key.WithKeys("n", "N"), - key.WithHelp("n/N", "no"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "switch options"), - ), -} - -func (q *quitDialogCmp) Init() tea.Cmd { - return nil -} - -func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab): - q.selectedNo = !q.selectedNo - return q, nil - case key.Matches(msg, helpKeys.EnterSpace): - if !q.selectedNo { - return q, tea.Quit - } - return q, util.CmdHandler(CloseQuitMsg{}) - case key.Matches(msg, helpKeys.Yes): - return q, tea.Quit - case key.Matches(msg, helpKeys.No): - return q, util.CmdHandler(CloseQuitMsg{}) - } - } - return q, nil -} - -func (q *quitDialogCmp) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - yesStyle := baseStyle - noStyle := baseStyle - spacerStyle := baseStyle.Background(t.Background()) - - if q.selectedNo { - noStyle = noStyle.Background(t.Primary()).Foreground(t.Background()) - yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary()) - } else { - yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background()) - noStyle = noStyle.Background(t.Background()).Foreground(t.Primary()) - } - - yesButton := yesStyle.Padding(0, 1).Render("Yes") - noButton := noStyle.Padding(0, 1).Render("No") - - buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton) - - width := lipgloss.Width(question) - remainingWidth := width - lipgloss.Width(buttons) - if remainingWidth > 0 { - buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons - } - - content := baseStyle.Render( - lipgloss.JoinVertical( - lipgloss.Center, - question, - "", - buttons, - ), - ) - - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) -} - -func (q *quitDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(helpKeys) -} - -func NewQuitCmp() QuitDialog { - return &quitDialogCmp{ - selectedNo: true, - } -} diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go deleted file mode 100644 index a29fa7131ed1b1abbe7ae170eb2af107ad5c5647..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/session.go +++ /dev/null @@ -1,230 +0,0 @@ -package dialog - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// SessionSelectedMsg is sent when a session is selected -type SessionSelectedMsg struct { - Session session.Session -} - -// CloseSessionDialogMsg is sent when the session dialog is closed -type CloseSessionDialogMsg struct{} - -// SessionDialog interface for the session switching dialog -type SessionDialog interface { - tea.Model - layout.Bindings - SetSessions(sessions []session.Session) - SetSelectedSession(sessionID string) -} - -type sessionDialogCmp struct { - sessions []session.Session - selectedIdx int - width int - height int - selectedSessionID string -} - -type sessionKeyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Escape key.Binding - J key.Binding - K key.Binding -} - -var sessionKeys = sessionKeyMap{ - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "previous session"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "next session"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select session"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), - ), - J: key.NewBinding( - key.WithKeys("j"), - key.WithHelp("j", "next session"), - ), - K: key.NewBinding( - key.WithKeys("k"), - key.WithHelp("k", "previous session"), - ), -} - -func (s *sessionDialogCmp) Init() tea.Cmd { - return nil -} - -func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K): - if s.selectedIdx > 0 { - s.selectedIdx-- - } - return s, nil - case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J): - if s.selectedIdx < len(s.sessions)-1 { - s.selectedIdx++ - } - return s, nil - case key.Matches(msg, sessionKeys.Enter): - if len(s.sessions) > 0 { - return s, util.CmdHandler(SessionSelectedMsg{ - Session: s.sessions[s.selectedIdx], - }) - } - case key.Matches(msg, sessionKeys.Escape): - return s, util.CmdHandler(CloseSessionDialogMsg{}) - } - case tea.WindowSizeMsg: - s.width = msg.Width - s.height = msg.Height - } - return s, nil -} - -func (s *sessionDialogCmp) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - if len(s.sessions) == 0 { - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(40). - Render("No sessions available") - } - - // Calculate max width needed for session titles - maxWidth := 40 // Minimum width - for _, sess := range s.sessions { - if len(sess.Title) > maxWidth-4 { // Account for padding - maxWidth = len(sess.Title) + 4 - } - } - - maxWidth = max(30, min(maxWidth, s.width-15)) // Limit width to avoid overflow - - // Limit height to avoid taking up too much screen space - maxVisibleSessions := min(10, len(s.sessions)) - - // Build the session list - sessionItems := make([]string, 0, maxVisibleSessions) - startIdx := 0 - - // If we have more sessions than can be displayed, adjust the start index - if len(s.sessions) > maxVisibleSessions { - // Center the selected item when possible - halfVisible := maxVisibleSessions / 2 - if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible { - startIdx = s.selectedIdx - halfVisible - } else if s.selectedIdx >= len(s.sessions)-halfVisible { - startIdx = len(s.sessions) - maxVisibleSessions - } - } - - endIdx := min(startIdx+maxVisibleSessions, len(s.sessions)) - - for i := startIdx; i < endIdx; i++ { - sess := s.sessions[i] - itemStyle := baseStyle.Width(maxWidth) - - if i == s.selectedIdx { - itemStyle = itemStyle. - Background(t.Primary()). - Foreground(t.Background()). - Bold(true) - } - - sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title)) - } - - title := baseStyle. - Foreground(t.Primary()). - Bold(true). - Width(maxWidth). - Padding(0, 1). - Render("Switch Session") - - content := lipgloss.JoinVertical( - lipgloss.Left, - title, - baseStyle.Width(maxWidth).Render(""), - baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)), - baseStyle.Width(maxWidth).Render(""), - ) - - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) -} - -func (s *sessionDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(sessionKeys) -} - -func (s *sessionDialogCmp) SetSessions(sessions []session.Session) { - s.sessions = sessions - - // If we have a selected session ID, find its index - if s.selectedSessionID != "" { - for i, sess := range sessions { - if sess.ID == s.selectedSessionID { - s.selectedIdx = i - return - } - } - } - - // Default to first session if selected not found - s.selectedIdx = 0 -} - -func (s *sessionDialogCmp) SetSelectedSession(sessionID string) { - s.selectedSessionID = sessionID - - // Update the selected index if sessions are already loaded - if len(s.sessions) > 0 { - for i, sess := range s.sessions { - if sess.ID == sessionID { - s.selectedIdx = i - return - } - } - } -} - -// NewSessionDialogCmp creates a new session switching dialog -func NewSessionDialogCmp() SessionDialog { - return &sessionDialogCmp{ - sessions: []session.Session{}, - selectedIdx: 0, - selectedSessionID: "", - } -} diff --git a/internal/tui/components/dialog/theme.go b/internal/tui/components/dialog/theme.go deleted file mode 100644 index d35d3e2b6df04ca6049728224533293ba0ef2285..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/theme.go +++ /dev/null @@ -1,198 +0,0 @@ -package dialog - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// ThemeChangedMsg is sent when the theme is changed -type ThemeChangedMsg struct { - ThemeName string -} - -// CloseThemeDialogMsg is sent when the theme dialog is closed -type CloseThemeDialogMsg struct{} - -// ThemeDialog interface for the theme switching dialog -type ThemeDialog interface { - tea.Model - layout.Bindings -} - -type themeDialogCmp struct { - themes []string - selectedIdx int - width int - height int - currentTheme string -} - -type themeKeyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Escape key.Binding - J key.Binding - K key.Binding -} - -var themeKeys = themeKeyMap{ - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "previous theme"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "next theme"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select theme"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), - ), - J: key.NewBinding( - key.WithKeys("j"), - key.WithHelp("j", "next theme"), - ), - K: key.NewBinding( - key.WithKeys("k"), - key.WithHelp("k", "previous theme"), - ), -} - -func (t *themeDialogCmp) Init() tea.Cmd { - // Load available themes and update selectedIdx based on current theme - t.themes = theme.AvailableThemes() - t.currentTheme = theme.CurrentThemeName() - - // Find the current theme in the list - for i, name := range t.themes { - if name == t.currentTheme { - t.selectedIdx = i - break - } - } - - return nil -} - -func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K): - if t.selectedIdx > 0 { - t.selectedIdx-- - } - return t, nil - case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J): - if t.selectedIdx < len(t.themes)-1 { - t.selectedIdx++ - } - return t, nil - case key.Matches(msg, themeKeys.Enter): - if len(t.themes) > 0 { - previousTheme := theme.CurrentThemeName() - selectedTheme := t.themes[t.selectedIdx] - if previousTheme == selectedTheme { - return t, util.CmdHandler(CloseThemeDialogMsg{}) - } - if err := theme.SetTheme(selectedTheme); err != nil { - return t, util.ReportError(err) - } - return t, util.CmdHandler(ThemeChangedMsg{ - ThemeName: selectedTheme, - }) - } - case key.Matches(msg, themeKeys.Escape): - return t, util.CmdHandler(CloseThemeDialogMsg{}) - } - case tea.WindowSizeMsg: - t.width = msg.Width - t.height = msg.Height - } - return t, nil -} - -func (t *themeDialogCmp) View() string { - currentTheme := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - if len(t.themes) == 0 { - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(currentTheme.Background()). - BorderForeground(currentTheme.TextMuted()). - Width(40). - Render("No themes available") - } - - // Calculate max width needed for theme names - maxWidth := 40 // Minimum width - for _, themeName := range t.themes { - if len(themeName) > maxWidth-4 { // Account for padding - maxWidth = len(themeName) + 4 - } - } - - maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow - - // Build the theme list - themeItems := make([]string, 0, len(t.themes)) - for i, themeName := range t.themes { - itemStyle := baseStyle.Width(maxWidth) - - if i == t.selectedIdx { - itemStyle = itemStyle. - Background(currentTheme.Primary()). - Foreground(currentTheme.Background()). - Bold(true) - } - - themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName)) - } - - title := baseStyle. - Foreground(currentTheme.Primary()). - Bold(true). - Width(maxWidth). - Padding(0, 1). - Render("Select Theme") - - content := lipgloss.JoinVertical( - lipgloss.Left, - title, - baseStyle.Width(maxWidth).Render(""), - baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)), - baseStyle.Width(maxWidth).Render(""), - ) - - return baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(currentTheme.Background()). - BorderForeground(currentTheme.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content) -} - -func (t *themeDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(themeKeys) -} - -// NewThemeDialogCmp creates a new theme switching dialog -func NewThemeDialogCmp() ThemeDialog { - return &themeDialogCmp{ - themes: []string{}, - selectedIdx: 0, - currentTheme: "", - } -} - diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go new file mode 100644 index 0000000000000000000000000000000000000000..1128acf21b031ab914662f6686ffc9f57b9b7653 --- /dev/null +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -0,0 +1,234 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +const ( + argumentsDialogID dialogs.DialogID = "arguments" +) + +// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. +type ShowArgumentsDialogMsg struct { + CommandID string + Content string + ArgNames []string +} + +// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. +type CloseArgumentsDialogMsg struct { + Submit bool + CommandID string + Content string + Args map[string]string +} + +// CommandArgumentsDialog represents the commands dialog. +type CommandArgumentsDialog interface { + dialogs.DialogModel +} + +type commandArgumentsDialogCmp struct { + width int + wWidth int // Width of the terminal window + wHeight int // Height of the terminal window + + inputs []textinput.Model + focusIndex int + keys ArgumentsDialogKeyMap + commandID string + content string + argNames []string + help help.Model +} + +func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog { + t := styles.CurrentTheme() + inputs := make([]textinput.Model, len(argNames)) + + for i, name := range argNames { + ti := textinput.New() + ti.Placeholder = fmt.Sprintf("Enter value for %s...", name) + ti.SetWidth(40) + ti.SetVirtualCursor(false) + ti.Prompt = "" + + ti.SetStyles(t.S().TextInput) + // Only focus the first input initially + if i == 0 { + ti.Focus() + } else { + ti.Blur() + } + + inputs[i] = ti + } + + return &commandArgumentsDialogCmp{ + inputs: inputs, + keys: DefaultArgumentsDialogKeyMap(), + commandID: commandID, + content: content, + argNames: argNames, + focusIndex: 0, + width: 60, + help: help.New(), + } +} + +// Init implements CommandArgumentsDialog. +func (c *commandArgumentsDialogCmp) Init() tea.Cmd { + return nil +} + +// Update implements CommandArgumentsDialog. +func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + c.wWidth = msg.Width + c.wHeight = msg.Height + case tea.KeyPressMsg: + switch { + case key.Matches(msg, c.keys.Confirm): + if c.focusIndex == len(c.inputs)-1 { + content := c.content + for i, name := range c.argNames { + value := c.inputs[i].Value() + placeholder := "$" + name + content = strings.ReplaceAll(content, placeholder, value) + } + return c, tea.Sequence( + util.CmdHandler(dialogs.CloseDialogMsg{}), + util.CmdHandler(CommandRunCustomMsg{ + Content: content, + }), + ) + } + // Otherwise, move to the next input + c.inputs[c.focusIndex].Blur() + c.focusIndex++ + c.inputs[c.focusIndex].Focus() + case key.Matches(msg, c.keys.Next): + // Move to the next input + c.inputs[c.focusIndex].Blur() + c.focusIndex = (c.focusIndex + 1) % len(c.inputs) + c.inputs[c.focusIndex].Focus() + case key.Matches(msg, c.keys.Previous): + // Move to the previous input + c.inputs[c.focusIndex].Blur() + c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs) + c.inputs[c.focusIndex].Focus() + + default: + var cmd tea.Cmd + c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg) + return c, cmd + } + } + return c, nil +} + +// View implements CommandArgumentsDialog. +func (c *commandArgumentsDialogCmp) View() tea.View { + t := styles.CurrentTheme() + baseStyle := t.S().Base + + title := lipgloss.NewStyle(). + Foreground(t.Primary). + Bold(true). + Padding(0, 1). + Render("Command Arguments") + + explanation := t.S().Text. + Padding(0, 1). + Render("This command requires arguments.") + + // Create input fields for each argument + inputFields := make([]string, len(c.inputs)) + for i, input := range c.inputs { + // Highlight the label of the focused input + labelStyle := baseStyle. + Padding(1, 1, 0, 1) + + if i == c.focusIndex { + labelStyle = labelStyle.Foreground(t.FgBase).Bold(true) + } else { + labelStyle = labelStyle.Foreground(t.FgMuted) + } + + label := labelStyle.Render(c.argNames[i] + ":") + + field := t.S().Text. + Padding(0, 1). + Render(input.View()) + + inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field) + } + + // Join all elements vertically + elements := []string{title, explanation} + elements = append(elements, inputFields...) + + c.help.ShowAll = false + helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys)) + elements = append(elements, "", helpText) + + content := lipgloss.JoinVertical( + lipgloss.Left, + elements..., + ) + + view := tea.NewView( + baseStyle.Padding(1, 1, 0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus). + Width(c.width). + Render(content), + ) + cursor := c.inputs[c.focusIndex].Cursor() + if cursor != nil { + cursor = c.moveCursor(cursor) + } + view.SetCursor(cursor) + return view +} + +func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { + row, col := c.Position() + offset := row + 3 + (1+c.focusIndex)*3 + cursor.Y += offset + cursor.X = cursor.X + col + 3 + return cursor +} + +func (c *commandArgumentsDialogCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(c.width). + Padding(1). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) +} + +func (c *commandArgumentsDialogCmp) Position() (int, int) { + row := c.wHeight / 2 + row -= c.wHeight / 2 + col := c.wWidth / 2 + col -= c.width / 2 + return row, col +} + +// ID implements CommandArgumentsDialog. +func (c *commandArgumentsDialogCmp) ID() dialogs.DialogID { + return argumentsDialogID +} diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..b140fc1246d36e806836359f5b17030e5b383a1b --- /dev/null +++ b/internal/tui/components/dialogs/commands/commands.go @@ -0,0 +1,287 @@ +package commands + +import ( + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" +) + +const ( + CommandsDialogID dialogs.DialogID = "commands" + + defaultWidth int = 70 +) + +const ( + SystemCommands int = iota + UserCommands +) + +// Command represents a command that can be executed +type Command struct { + ID string + Title string + Description string + Shortcut string // Optional shortcut for the command + Handler func(cmd Command) tea.Cmd +} + +// CommandsDialog represents the commands dialog. +type CommandsDialog interface { + dialogs.DialogModel +} + +type commandDialogCmp struct { + width int + wWidth int // Width of the terminal window + wHeight int // Height of the terminal window + + commandList list.ListModel + keyMap CommandsDialogKeyMap + help help.Model + commandType int // SystemCommands or UserCommands + userCommands []Command // User-defined commands + sessionID string // Current session ID +} + +type ( + SwitchSessionsMsg struct{} + SwitchModelMsg struct{} + CompactMsg struct { + SessionID string + } +) + +func NewCommandDialog(sessionID string) CommandsDialog { + listKeyMap := list.DefaultKeyMap() + keyMap := DefaultCommandsDialogKeyMap() + + listKeyMap.Down.SetEnabled(false) + listKeyMap.Up.SetEnabled(false) + listKeyMap.NDown.SetEnabled(false) + listKeyMap.NUp.SetEnabled(false) + listKeyMap.HalfPageDown.SetEnabled(false) + listKeyMap.HalfPageUp.SetEnabled(false) + listKeyMap.Home.SetEnabled(false) + listKeyMap.End.SetEnabled(false) + + listKeyMap.DownOneItem = keyMap.Next + listKeyMap.UpOneItem = keyMap.Previous + + t := styles.CurrentTheme() + commandList := list.New( + list.WithFilterable(true), + list.WithKeyMap(listKeyMap), + list.WithWrapNavigation(true), + ) + help := help.New() + help.Styles = t.S().Help + return &commandDialogCmp{ + commandList: commandList, + width: defaultWidth, + keyMap: DefaultCommandsDialogKeyMap(), + help: help, + commandType: SystemCommands, + sessionID: sessionID, + } +} + +func (c *commandDialogCmp) Init() tea.Cmd { + commands, err := LoadCustomCommands() + if err != nil { + return util.ReportError(err) + } + + c.userCommands = commands + c.SetCommandType(c.commandType) + return c.commandList.Init() +} + +func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + c.wWidth = msg.Width + c.wHeight = msg.Height + return c, c.commandList.SetSize(c.listWidth(), c.listHeight()) + case tea.KeyPressMsg: + switch { + case key.Matches(msg, c.keyMap.Select): + selectedItemInx := c.commandList.SelectedIndex() + if selectedItemInx == list.NoSelection { + return c, nil // No item selected, do nothing + } + items := c.commandList.Items() + selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command) + return c, tea.Sequence( + util.CmdHandler(dialogs.CloseDialogMsg{}), + selectedItem.Handler(selectedItem), + ) + case key.Matches(msg, c.keyMap.Tab): + // Toggle command type between System and User commands + if c.commandType == SystemCommands { + return c, c.SetCommandType(UserCommands) + } else { + return c, c.SetCommandType(SystemCommands) + } + case key.Matches(msg, c.keyMap.Close): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) + default: + u, cmd := c.commandList.Update(msg) + c.commandList = u.(list.ListModel) + return c, cmd + } + } + return c, nil +} + +func (c *commandDialogCmp) View() tea.View { + t := styles.CurrentTheme() + listView := c.commandList.View() + radio := c.commandTypeRadio() + content := lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio), + listView.String(), + "", + t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)), + ) + v := tea.NewView(c.style().Render(content)) + if listView.Cursor() != nil { + c := c.moveCursor(listView.Cursor()) + v.SetCursor(c) + } + return v +} + +func (c *commandDialogCmp) commandTypeRadio() string { + t := styles.CurrentTheme() + choices := []string{"System", "User"} + iconSelected := "◉" + iconUnselected := "○" + if c.commandType == SystemCommands { + return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1]) + } + return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1]) +} + +func (c *commandDialogCmp) listWidth() int { + return defaultWidth - 2 // 4 for padding +} + +func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd { + c.commandType = commandType + + var commands []Command + if c.commandType == SystemCommands { + commands = c.defaultCommands() + } else { + commands = c.userCommands + } + + commandItems := []util.Model{} + for _, cmd := range commands { + opts := []completions.CompletionOption{} + if cmd.Shortcut != "" { + opts = append(opts, completions.WithShortcut(cmd.Shortcut)) + } + commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...)) + } + return c.commandList.SetItems(commandItems) +} + +func (c *commandDialogCmp) listHeight() int { + listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections + return min(listHeigh, c.wHeight/2) +} + +func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { + row, col := c.Position() + offset := row + 3 + cursor.Y += offset + cursor.X = cursor.X + col + 2 + return cursor +} + +func (c *commandDialogCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(c.width). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) +} + +func (c *commandDialogCmp) Position() (int, int) { + row := c.wHeight/4 - 2 // just a bit above the center + col := c.wWidth / 2 + col -= c.width / 2 + return row, col +} + +func (c *commandDialogCmp) defaultCommands() []Command { + commands := []Command{ + { + ID: "init", + Title: "Initialize Project", + Description: "Create/Update the Crush.md memory file", + Handler: func(cmd Command) tea.Cmd { + prompt := `Please analyze this codebase and create a Crush.md file containing: + 1. Build/lint/test commands - especially for running a single test + 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + + The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. + If there's already a crush.md, improve it. + If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.` + return util.CmdHandler(chat.SendMsg{ + Text: prompt, + }) + }, + }, + } + + // Only show compact command if there's an active session + if c.sessionID != "" { + commands = append(commands, Command{ + ID: "compact", + Title: "Compact Session", + Description: "Summarize the current session and create a new one with the summary", + Handler: func(cmd Command) tea.Cmd { + return util.CmdHandler(CompactMsg{ + SessionID: c.sessionID, + }) + }, + }) + } + + return append(commands, []Command{ + { + ID: "switch_session", + Title: "Switch Session", + Description: "Switch to a different session", + Shortcut: "ctrl+s", + Handler: func(cmd Command) tea.Cmd { + return util.CmdHandler(SwitchSessionsMsg{}) + }, + }, + { + ID: "switch_model", + Title: "Switch Model", + Description: "Switch to a different model", + Handler: func(cmd Command) tea.Cmd { + return util.CmdHandler(SwitchModelMsg{}) + }, + }, + }...) +} + +func (c *commandDialogCmp) ID() dialogs.DialogID { + return CommandsDialogID +} diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go new file mode 100644 index 0000000000000000000000000000000000000000..b0db2c9c35424eb7f3ef9ddc2b20d85efcd7e6a4 --- /dev/null +++ b/internal/tui/components/dialogs/commands/item.go @@ -0,0 +1,57 @@ +package commands + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/x/ansi" +) + +type ItemSection interface { + util.Model + layout.Sizeable + list.SectionHeader +} +type itemSectionModel struct { + width int + title string + noPadding bool // No padding for the section header +} + +func NewItemSection(title string) ItemSection { + return &itemSectionModel{ + title: title, + } +} + +func (m *itemSectionModel) Init() tea.Cmd { + return nil +} + +func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m *itemSectionModel) View() tea.View { + t := styles.CurrentTheme() + title := ansi.Truncate(m.title, m.width-2, "…") + style := t.S().Base.Padding(1, 1, 0, 1) + title = t.S().Muted.Render(title) + return tea.NewView(style.Render(core.Section(title, m.width-2))) +} + +func (m *itemSectionModel) GetSize() (int, int) { + return m.width, 1 +} + +func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd { + m.width = width + return nil +} + +func (m *itemSectionModel) IsSectionHeader() bool { + return true +} diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..96df76a20ef4764f201abd504fea2ee15270c76d --- /dev/null +++ b/internal/tui/components/dialogs/commands/keys.go @@ -0,0 +1,107 @@ +package commands + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type CommandsDialogKeyMap struct { + Select, + Next, + Previous, + Tab, + Close key.Binding +} + +func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap { + return CommandsDialogKeyMap{ + Select: key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Next: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ), + Previous: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch selection"), + ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k CommandsDialogKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Tab, + key.NewBinding( + key.WithKeys("down", "up"), + key.WithHelp("↑↓", "choose"), + ), + k.Select, + k.Close, + } +} + +type ArgumentsDialogKeyMap struct { + Confirm key.Binding + Next key.Binding + Previous key.Binding +} + +func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap { + return ArgumentsDialogKeyMap{ + Confirm: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + + Next: key.NewBinding( + key.WithKeys("tab", "down"), + key.WithHelp("tab/↓", "next"), + ), + Previous: key.NewBinding( + key.WithKeys("shift+tab", "up"), + key.WithHelp("shift+tab/↑", "previous"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Confirm, + k.Next, + k.Previous, + } +} diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go new file mode 100644 index 0000000000000000000000000000000000000000..9f70afa3cd60342028b6d3fd00e017221c179686 --- /dev/null +++ b/internal/tui/components/dialogs/commands/loader.go @@ -0,0 +1,202 @@ +package commands + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/tui/util" +) + +const ( + UserCommandPrefix = "user:" + ProjectCommandPrefix = "project:" +) + +var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) + +type commandLoader struct { + sources []commandSource +} + +type commandSource struct { + path string + prefix string +} + +func LoadCustomCommands() ([]Command, error) { + cfg := config.Get() + if cfg == nil { + return nil, fmt.Errorf("config not loaded") + } + + loader := &commandLoader{ + sources: buildCommandSources(cfg), + } + + return loader.loadAll() +} + +func buildCommandSources(cfg *config.Config) []commandSource { + var sources []commandSource + + // XDG config directory + if dir := getXDGCommandsDir(); dir != "" { + sources = append(sources, commandSource{ + path: dir, + prefix: UserCommandPrefix, + }) + } + + // Home directory + if home, err := os.UserHomeDir(); err == nil { + sources = append(sources, commandSource{ + path: filepath.Join(home, ".crush", "commands"), + prefix: UserCommandPrefix, + }) + } + + // Project directory + sources = append(sources, commandSource{ + path: filepath.Join(cfg.Data.Directory, "commands"), + prefix: ProjectCommandPrefix, + }) + + return sources +} + +func getXDGCommandsDir() string { + xdgHome := os.Getenv("XDG_CONFIG_HOME") + if xdgHome == "" { + if home, err := os.UserHomeDir(); err == nil { + xdgHome = filepath.Join(home, ".config") + } + } + if xdgHome != "" { + return filepath.Join(xdgHome, "crush", "commands") + } + return "" +} + +func (l *commandLoader) loadAll() ([]Command, error) { + var commands []Command + + for _, source := range l.sources { + if cmds, err := l.loadFromSource(source); err == nil { + commands = append(commands, cmds...) + } + } + + return commands, nil +} + +func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) { + if err := ensureDir(source.path); err != nil { + return nil, err + } + + var commands []Command + + err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) { + return err + } + + cmd, err := l.loadCommand(path, source.path, source.prefix) + if err != nil { + return nil // Skip invalid files + } + + commands = append(commands, cmd) + return nil + }) + + return commands, err +} + +func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) { + content, err := os.ReadFile(path) + if err != nil { + return Command{}, err + } + + id := buildCommandID(path, baseDir, prefix) + + return Command{ + ID: id, + Title: id, + Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)), + Handler: createCommandHandler(id, string(content)), + }, nil +} + +func buildCommandID(path, baseDir, prefix string) string { + relPath, _ := filepath.Rel(baseDir, path) + parts := strings.Split(relPath, string(filepath.Separator)) + + // Remove .md extension from last part + if len(parts) > 0 { + lastIdx := len(parts) - 1 + parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx])) + } + + return prefix + strings.Join(parts, ":") +} + +func createCommandHandler(id string, content string) func(Command) tea.Cmd { + return func(cmd Command) tea.Cmd { + args := extractArgNames(content) + + if len(args) > 0 { + return util.CmdHandler(ShowArgumentsDialogMsg{ + CommandID: id, + Content: content, + ArgNames: args, + }) + } + + return util.CmdHandler(CommandRunCustomMsg{ + Content: content, + }) + } +} + +func extractArgNames(content string) []string { + matches := namedArgPattern.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return nil + } + + seen := make(map[string]bool) + var args []string + + for _, match := range matches { + arg := match[1] + if !seen[arg] { + seen[arg] = true + args = append(args, arg) + } + } + + return args +} + +func ensureDir(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return os.MkdirAll(path, 0o755) + } + return nil +} + +func isMarkdownFile(name string) bool { + return strings.HasSuffix(strings.ToLower(name), ".md") +} + +type CommandRunCustomMsg struct { + Content string +} diff --git a/internal/tui/components/dialogs/compact/compact.go b/internal/tui/components/dialogs/compact/compact.go new file mode 100644 index 0000000000000000000000000000000000000000..895053279ff916b113051aca3eeb1652ec82936e --- /dev/null +++ b/internal/tui/components/dialogs/compact/compact.go @@ -0,0 +1,265 @@ +package compact + +import ( + "context" + + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" +) + +const CompactDialogID dialogs.DialogID = "compact" + +// CompactDialog interface for the session compact dialog +type CompactDialog interface { + dialogs.DialogModel +} + +type compactDialogCmp struct { + wWidth, wHeight int + width, height int + selected int + keyMap KeyMap + sessionID string + state compactState + progress string + agent agent.Service + noAsk bool // If true, skip confirmation dialog +} + +type compactState int + +const ( + stateConfirm compactState = iota + stateCompacting + stateError +) + +// NewCompactDialogCmp creates a new session compact dialog +func NewCompactDialogCmp(agent agent.Service, sessionID string, noAsk bool) CompactDialog { + return &compactDialogCmp{ + sessionID: sessionID, + keyMap: DefaultKeyMap(), + state: stateConfirm, + selected: 0, + agent: agent, + noAsk: noAsk, + } +} + +func (c *compactDialogCmp) Init() tea.Cmd { + if c.noAsk { + // If noAsk is true, skip confirmation and start compaction immediately + return c.startCompaction() + } + return nil +} + +func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + c.wWidth = msg.Width + c.wHeight = msg.Height + cmd := c.SetSize() + return c, cmd + + case tea.KeyPressMsg: + switch c.state { + case stateConfirm: + switch { + case key.Matches(msg, c.keyMap.ChangeSelection): + c.selected = (c.selected + 1) % 2 + return c, nil + case key.Matches(msg, c.keyMap.Select): + if c.selected == 0 { + return c, c.startCompaction() + } else { + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) + } + case key.Matches(msg, c.keyMap.Y): + return c, c.startCompaction() + case key.Matches(msg, c.keyMap.N): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) + case key.Matches(msg, c.keyMap.Close): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) + } + case stateCompacting: + switch { + case key.Matches(msg, c.keyMap.Close): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) + } + case stateError: + switch { + case key.Matches(msg, c.keyMap.Select): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) + case key.Matches(msg, c.keyMap.Close): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) + } + } + + case agent.AgentEvent: + if msg.Type == agent.AgentEventTypeSummarize { + if msg.Error != nil { + c.state = stateError + c.progress = "Error: " + msg.Error.Error() + } else if msg.Done { + return c, util.CmdHandler( + dialogs.CloseDialogMsg{}, + ) + } else { + c.progress = msg.Progress + } + } + return c, nil + } + + return c, nil +} + +func (c *compactDialogCmp) startCompaction() tea.Cmd { + c.state = stateCompacting + c.progress = "Starting summarization..." + return func() tea.Msg { + err := c.agent.Summarize(context.Background(), c.sessionID) + if err != nil { + c.state = stateError + c.progress = "Error: " + err.Error() + } + return nil + } +} + +func (c *compactDialogCmp) renderButtons() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base + + buttons := []core.ButtonOpts{ + { + Text: "Yes", + UnderlineIndex: 0, // "Y" + Selected: c.selected == 0, + }, + { + Text: "No", + UnderlineIndex: 0, // "N" + Selected: c.selected == 1, + }, + } + + content := core.SelectableButtons(buttons, " ") + + return baseStyle.AlignHorizontal(lipgloss.Right).Width(c.width - 4).Render(content) +} + +func (c *compactDialogCmp) renderContent() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base + + switch c.state { + case stateConfirm: + explanation := t.S().Text. + Width(c.width - 4). + Render("This will summarize the current session and reset the context. The conversation history will be condensed into a summary to free up context space while preserving important information.") + + question := t.S().Text. + Width(c.width - 4). + Render("Do you want to continue?") + + return baseStyle.Render(lipgloss.JoinVertical( + lipgloss.Left, + explanation, + "", + question, + )) + case stateCompacting: + return baseStyle.Render(lipgloss.JoinVertical( + lipgloss.Left, + c.progress, + "", + "Please wait...", + )) + case stateError: + return baseStyle.Render(lipgloss.JoinVertical( + lipgloss.Left, + c.progress, + "", + "Press Enter to close", + )) + } + return "" +} + +func (c *compactDialogCmp) render() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base + + var title string + switch c.state { + case stateConfirm: + title = "Compact Session" + case stateCompacting: + title = "Compacting Session" + case stateError: + title = "Compact Failed" + } + + titleView := core.Title(title, c.width-4) + content := c.renderContent() + + var dialogContent string + if c.state == stateConfirm { + buttons := c.renderButtons() + dialogContent = lipgloss.JoinVertical( + lipgloss.Top, + titleView, + "", + content, + "", + buttons, + "", + ) + } else { + dialogContent = lipgloss.JoinVertical( + lipgloss.Top, + titleView, + "", + content, + "", + ) + } + + return baseStyle. + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus). + Width(c.width). + Render(dialogContent) +} + +func (c *compactDialogCmp) View() tea.View { + return tea.NewView(c.render()) +} + +// SetSize sets the size of the component. +func (c *compactDialogCmp) SetSize() tea.Cmd { + c.width = min(90, c.wWidth) + c.height = min(15, c.wHeight) + return nil +} + +func (c *compactDialogCmp) Position() (int, int) { + row := (c.wHeight / 2) - (c.height / 2) + col := (c.wWidth / 2) - (c.width / 2) + return row, col +} + +// ID implements CompactDialog. +func (c *compactDialogCmp) ID() dialogs.DialogID { + return CompactDialogID +} diff --git a/internal/tui/components/dialogs/compact/keys.go b/internal/tui/components/dialogs/compact/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..3d15d3e4caad747fbd2511ce09f5f4ce994236b6 --- /dev/null +++ b/internal/tui/components/dialogs/compact/keys.go @@ -0,0 +1,61 @@ +package compact + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +// KeyMap defines the key bindings for the compact dialog. +type KeyMap struct { + ChangeSelection key.Binding + Select key.Binding + Y key.Binding + N key.Binding + Close key.Binding +} + +// DefaultKeyMap returns the default key bindings for the compact dialog. +func DefaultKeyMap() KeyMap { + return KeyMap{ + ChangeSelection: key.NewBinding( + key.WithKeys("tab", "left", "right", "h", "l"), + key.WithHelp("tab/←/→", "toggle selection"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + Y: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "yes"), + ), + N: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "no"), + ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.ChangeSelection, + k.Select, + k.Close, + } +} diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go new file mode 100644 index 0000000000000000000000000000000000000000..9153500a724915e858d18ab449c3b16ced39a548 --- /dev/null +++ b/internal/tui/components/dialogs/dialogs.go @@ -0,0 +1,162 @@ +package dialogs + +import ( + "slices" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +type DialogID string + +// DialogModel represents a dialog component that can be displayed. +type DialogModel interface { + util.Model + Position() (int, int) + ID() DialogID +} + +// CloseCallback allows dialogs to perform cleanup when closed. +type CloseCallback interface { + Close() tea.Cmd +} + +// OpenDialogMsg is sent to open a new dialog with specified dimensions. +type OpenDialogMsg struct { + Model DialogModel +} + +// CloseDialogMsg is sent to close the topmost dialog. +type CloseDialogMsg struct{} + +// DialogCmp manages a stack of dialogs with keyboard navigation. +type DialogCmp interface { + tea.Model + + Dialogs() []DialogModel + HasDialogs() bool + GetLayers() []*lipgloss.Layer + ActiveView() *tea.View + ActiveDialogId() DialogID +} + +type dialogCmp struct { + width, height int + dialogs []DialogModel + idMap map[DialogID]int + keyMap KeyMap +} + +// NewDialogCmp creates a new dialog manager. +func NewDialogCmp() DialogCmp { + return dialogCmp{ + dialogs: []DialogModel{}, + keyMap: DefaultKeyMap(), + idMap: make(map[DialogID]int), + } +} + +func (d dialogCmp) Init() tea.Cmd { + return nil +} + +// Update handles dialog lifecycle and forwards messages to the active dialog. +func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + var cmds []tea.Cmd + d.width = msg.Width + d.height = msg.Height + for i := range d.dialogs { + u, cmd := d.dialogs[i].Update(msg) + d.dialogs[i] = u.(DialogModel) + cmds = append(cmds, cmd) + } + return d, tea.Batch(cmds...) + case OpenDialogMsg: + return d.handleOpen(msg) + case CloseDialogMsg: + if len(d.dialogs) == 0 { + return d, nil + } + inx := len(d.dialogs) - 1 + dialog := d.dialogs[inx] + delete(d.idMap, dialog.ID()) + d.dialogs = d.dialogs[:len(d.dialogs)-1] + if closeable, ok := dialog.(CloseCallback); ok { + return d, closeable.Close() + } + return d, nil + } + if d.HasDialogs() { + lastIndex := len(d.dialogs) - 1 + u, cmd := d.dialogs[lastIndex].Update(msg) + d.dialogs[lastIndex] = u.(DialogModel) + return d, cmd + } + return d, nil +} + +func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) { + if d.HasDialogs() { + dialog := d.dialogs[len(d.dialogs)-1] + if dialog.ID() == msg.Model.ID() { + return d, nil // Do not open a dialog if it's already the topmost one + } + if dialog.ID() == "quit" { + return d, nil // Do not open dialogs on top of quit + } + } + // if the dialog is already in the stack make it the last item + if _, ok := d.idMap[msg.Model.ID()]; ok { + existing := d.dialogs[d.idMap[msg.Model.ID()]] + // Reuse the model so we keep the state + msg.Model = existing + d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1) + } + d.idMap[msg.Model.ID()] = len(d.dialogs) + d.dialogs = append(d.dialogs, msg.Model) + var cmds []tea.Cmd + cmd := msg.Model.Init() + cmds = append(cmds, cmd) + _, cmd = msg.Model.Update(tea.WindowSizeMsg{ + Width: d.width, + Height: d.height, + }) + cmds = append(cmds, cmd) + return d, tea.Batch(cmds...) +} + +func (d dialogCmp) Dialogs() []DialogModel { + return d.dialogs +} + +func (d dialogCmp) ActiveView() *tea.View { + if len(d.dialogs) == 0 { + return nil + } + view := d.dialogs[len(d.dialogs)-1].View() + return &view +} + +func (d dialogCmp) ActiveDialogId() DialogID { + if len(d.dialogs) == 0 { + return "" + } + return d.dialogs[len(d.dialogs)-1].ID() +} + +func (d dialogCmp) GetLayers() []*lipgloss.Layer { + layers := []*lipgloss.Layer{} + for _, dialog := range d.Dialogs() { + dialogView := dialog.View().String() + row, col := dialog.Position() + layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row)) + } + return layers +} + +func (d dialogCmp) HasDialogs() bool { + return len(d.dialogs) > 0 +} diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..916209b6f6371b7c5961f9fbc507f9c680f9e59b --- /dev/null +++ b/internal/tui/components/dialogs/filepicker/filepicker.go @@ -0,0 +1,229 @@ +package filepicker + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/v2/filepicker" + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/components/image" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +const ( + maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB + FilePickerID = "filepicker" + fileSelectionHight = 10 +) + +type FilePickedMsg struct { + Attachment message.Attachment +} + +type FilePicker interface { + dialogs.DialogModel +} + +type model struct { + wWidth int + wHeight int + width int + filePicker filepicker.Model + highlightedFile string + image image.Model + keyMap KeyMap + help help.Model +} + +func NewFilePickerCmp() FilePicker { + t := styles.CurrentTheme() + fp := filepicker.New() + fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"} + fp.CurrentDirectory, _ = os.UserHomeDir() + fp.ShowPermissions = false + fp.ShowSize = false + fp.AutoHeight = false + fp.Styles = t.S().FilePicker + fp.Cursor = "" + fp.SetHeight(fileSelectionHight) + + image := image.New(1, 1, "") + + help := help.New() + help.Styles = t.S().Help + return &model{ + filePicker: fp, + image: image, + keyMap: DefaultKeyMap(), + help: help, + } +} + +func (m *model) Init() tea.Cmd { + return m.filePicker.Init() +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.wWidth = msg.Width + m.wHeight = msg.Height + m.width = min(70, m.wWidth) + styles := m.filePicker.Styles + styles.Directory = styles.Directory.Width(m.width - 4) + styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4) + styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4) + styles.File = styles.File.Width(m.width) + m.filePicker.Styles = styles + return m, nil + case tea.KeyPressMsg: + if key.Matches(msg, m.keyMap.Close) { + return m, util.CmdHandler(dialogs.CloseDialogMsg{}) + } + if key.Matches(msg, m.filePicker.KeyMap.Back) { + // make sure we don't go back if we are at the home directory + homeDir, _ := os.UserHomeDir() + if m.filePicker.CurrentDirectory == homeDir { + return m, nil + } + } + } + + var cmd tea.Cmd + var cmds []tea.Cmd + m.filePicker, cmd = m.filePicker.Update(msg) + cmds = append(cmds, cmd) + if m.highlightedFile != m.currentImage() && m.currentImage() != "" { + w, h := m.imagePreviewSize() + cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage()) + cmds = append(cmds, cmd) + } + m.highlightedFile = m.currentImage() + + // Did the user select a file? + if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect { + // Get the path of the selected file. + return m, tea.Sequence( + util.CmdHandler(dialogs.CloseDialogMsg{}), + func() tea.Msg { + isFileLarge, err := ValidateFileSize(path, maxAttachmentSize) + if err != nil { + logging.ErrorPersist("unable to read the image") + return nil + } + if isFileLarge { + logging.ErrorPersist("file too large, max 5MB") + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + logging.ErrorPersist("Unable read selected file") + return nil + } + + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := filepath.Base(path) + attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content} + return FilePickedMsg{ + Attachment: attachment, + } + }, + ) + } + m.image, cmd = m.image.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m *model) View() tea.View { + t := styles.CurrentTheme() + + content := lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)), + m.imagePreview(), + m.filePicker.View(), + t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), + ) + return tea.NewView(m.style().Render(content)) +} + +func (m *model) currentImage() string { + for _, ext := range m.filePicker.AllowedTypes { + if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) { + return m.filePicker.HighlightedPath() + } + } + return "" +} + +func (m *model) imagePreview() string { + t := styles.CurrentTheme() + w, h := m.imagePreviewSize() + if m.currentImage() == "" { + imgPreview := t.S().Base. + Width(w). + Height(h). + Background(t.BgOverlay) + + return m.imagePreviewStyle().Render(imgPreview.Render()) + } + + return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View()) +} + +func (m *model) imagePreviewStyle() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base.Padding(1, 1, 1, 1) +} + +func (m *model) imagePreviewSize() (int, int) { + return m.width - 4, min(20, m.wHeight/2) +} + +func (m *model) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(m.width). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) +} + +// ID implements FilePicker. +func (m *model) ID() dialogs.DialogID { + return FilePickerID +} + +// Position implements FilePicker. +func (m *model) Position() (int, int) { + row := m.wHeight/4 - 2 // just a bit above the center + col := m.wWidth / 2 + col -= m.width / 2 + return row, col +} + +func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) { + fileInfo, err := os.Stat(filePath) + if err != nil { + return false, fmt.Errorf("error getting file info: %w", err) + } + + if fileInfo.Size() > sizeLimit { + return true, nil + } + + return false, nil +} diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..0143eaaddd0b938c458c5f5995497cb94d782735 --- /dev/null +++ b/internal/tui/components/dialogs/filepicker/keys.go @@ -0,0 +1,69 @@ +package filepicker + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +// KeyMap defines keyboard bindings for dialog management. +type KeyMap struct { + Select, + Down, + Up, + Forward, + Backward, + Close key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "accept"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("down/j", "move down"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("up/k", "move up"), + ), + Forward: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("right/l", "move forward"), + ), + Backward: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("left/h", "move backward"), + ), + + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "close/exit"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding( + key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"), + key.WithHelp("↑↓←→", "navigate"), + ), + k.Select, + k.Close, + } +} diff --git a/internal/tui/components/dialogs/init/init.go b/internal/tui/components/dialogs/init/init.go new file mode 100644 index 0000000000000000000000000000000000000000..ff4cbfb4d7b6933523cc873019758c9203ff8657 --- /dev/null +++ b/internal/tui/components/dialogs/init/init.go @@ -0,0 +1,213 @@ +package init + +import ( + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "github.com/charmbracelet/crush/internal/config" + cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" +) + +const InitDialogID dialogs.DialogID = "init" + +// InitDialogCmp is a component that asks the user if they want to initialize the project. +type InitDialogCmp interface { + dialogs.DialogModel +} + +type initDialogCmp struct { + wWidth, wHeight int + width, height int + selected int + keyMap KeyMap +} + +// NewInitDialogCmp creates a new InitDialogCmp. +func NewInitDialogCmp() InitDialogCmp { + return &initDialogCmp{ + selected: 0, + keyMap: DefaultKeyMap(), + } +} + +// Init implements tea.Model. +func (m *initDialogCmp) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model. +func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.wWidth = msg.Width + m.wHeight = msg.Height + cmd := m.SetSize() + return m, cmd + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Close): + return m, tea.Batch( + util.CmdHandler(dialogs.CloseDialogMsg{}), + m.handleInitialization(false), + ) + case key.Matches(msg, m.keyMap.ChangeSelection): + m.selected = (m.selected + 1) % 2 + return m, nil + case key.Matches(msg, m.keyMap.Select): + return m, tea.Batch( + util.CmdHandler(dialogs.CloseDialogMsg{}), + m.handleInitialization(m.selected == 0), + ) + case key.Matches(msg, m.keyMap.Y): + return m, tea.Batch( + util.CmdHandler(dialogs.CloseDialogMsg{}), + m.handleInitialization(true), + ) + case key.Matches(msg, m.keyMap.N): + return m, tea.Batch( + util.CmdHandler(dialogs.CloseDialogMsg{}), + m.handleInitialization(false), + ) + } + } + return m, nil +} + +func (m *initDialogCmp) renderButtons() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base + + buttons := []core.ButtonOpts{ + { + Text: "Yes", + UnderlineIndex: 0, // "Y" + Selected: m.selected == 0, + }, + { + Text: "No", + UnderlineIndex: 0, // "N" + Selected: m.selected == 1, + }, + } + + content := core.SelectableButtons(buttons, " ") + + return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content) +} + +func (m *initDialogCmp) renderContent() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base + + explanation := t.S().Text. + Width(m.width - 4). + Render("Initialization generates a new Crush.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.") + + question := t.S().Text. + Width(m.width - 4). + Render("Would you like to initialize this project?") + + return baseStyle.Render(lipgloss.JoinVertical( + lipgloss.Left, + explanation, + "", + question, + )) +} + +func (m *initDialogCmp) render() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base + title := core.Title("Initialize Project", m.width-4) + + content := m.renderContent() + buttons := m.renderButtons() + + dialogContent := lipgloss.JoinVertical( + lipgloss.Top, + title, + "", + content, + "", + buttons, + "", + ) + + return baseStyle. + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus). + Width(m.width). + Render(dialogContent) +} + +// View implements tea.Model. +func (m *initDialogCmp) View() tea.View { + return tea.NewView(m.render()) +} + +// SetSize sets the size of the component. +func (m *initDialogCmp) SetSize() tea.Cmd { + m.width = min(90, m.wWidth) + m.height = min(15, m.wHeight) + return nil +} + +// ID implements DialogModel. +func (m *initDialogCmp) ID() dialogs.DialogID { + return InitDialogID +} + +// Position implements DialogModel. +func (m *initDialogCmp) Position() (int, int) { + row := (m.wHeight / 2) - (m.height / 2) + col := (m.wWidth / 2) - (m.width / 2) + return row, col +} + +// handleInitialization handles the initialization logic when the dialog is closed. +func (m *initDialogCmp) handleInitialization(initialize bool) tea.Cmd { + if initialize { + // Run the initialization command + prompt := `Please analyze this codebase and create a Crush.md file containing: +1. Build/lint/test commands - especially for running a single test +2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + +The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. +If there's already a crush.md, improve it. +If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.` + + // Mark the project as initialized + if err := config.MarkProjectInitialized(); err != nil { + return util.ReportError(err) + } + + return tea.Sequence( + util.CmdHandler(cmpChat.SessionClearedMsg{}), + util.CmdHandler(cmpChat.SendMsg{ + Text: prompt, + }), + ) + } else { + // Mark the project as initialized without running the command + if err := config.MarkProjectInitialized(); err != nil { + return util.ReportError(err) + } + } + return nil +} + +// CloseInitDialogMsg is a message that is sent when the init dialog is closed. +type CloseInitDialogMsg struct { + Initialize bool +} + +// ShowInitDialogMsg is a message that is sent to show the init dialog. +type ShowInitDialogMsg struct { + Show bool +} diff --git a/internal/tui/components/dialogs/init/keys.go b/internal/tui/components/dialogs/init/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..864436562c5eccdea48af53899c9847e227304e5 --- /dev/null +++ b/internal/tui/components/dialogs/init/keys.go @@ -0,0 +1,59 @@ +package init + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + ChangeSelection, + Select, + Y, + N, + Close key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + ChangeSelection: key.NewBinding( + key.WithKeys("tab", "left", "right", "h", "l"), + key.WithHelp("tab/←/→", "toggle selection"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + Y: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "yes"), + ), + N: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "no"), + ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.ChangeSelection, + k.Select, + k.Close, + } +} diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..83334cf4c9c315151f915d75be9470de21cff961 --- /dev/null +++ b/internal/tui/components/dialogs/keys.go @@ -0,0 +1,37 @@ +package dialogs + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +// KeyMap defines keyboard bindings for dialog management. +type KeyMap struct { + Close key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Close: key.NewBinding( + key.WithKeys("esc"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Close, + } +} diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..94c08e37afa6002f3b4258c5ef8377cf62b368f0 --- /dev/null +++ b/internal/tui/components/dialogs/models/keys.go @@ -0,0 +1,58 @@ +package models + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + Select, + Next, + Previous, + Close key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Select: key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Next: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ), + Previous: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding( + + key.WithKeys("down", "up"), + key.WithHelp("↑↓", "choose"), + ), + k.Select, + k.Close, + } +} diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go new file mode 100644 index 0000000000000000000000000000000000000000..f8d23006929fa42cfb5d1a6d2841080d2541b330 --- /dev/null +++ b/internal/tui/components/dialogs/models/models.go @@ -0,0 +1,263 @@ +package models + +import ( + "slices" + + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +const ( + ModelsDialogID dialogs.DialogID = "models" + + defaultWidth = 60 +) + +// ModelSelectedMsg is sent when a model is selected +type ModelSelectedMsg struct { + Model models.Model +} + +// CloseModelDialogMsg is sent when a model is selected +type CloseModelDialogMsg struct{} + +// ModelDialog interface for the model selection dialog +type ModelDialog interface { + dialogs.DialogModel +} + +type modelDialogCmp struct { + width int + wWidth int // Width of the terminal window + wHeight int // Height of the terminal window + + modelList list.ListModel + keyMap KeyMap + help help.Model +} + +func NewModelDialogCmp() ModelDialog { + listKeyMap := list.DefaultKeyMap() + keyMap := DefaultKeyMap() + + listKeyMap.Down.SetEnabled(false) + listKeyMap.Up.SetEnabled(false) + listKeyMap.NDown.SetEnabled(false) + listKeyMap.NUp.SetEnabled(false) + listKeyMap.HalfPageDown.SetEnabled(false) + listKeyMap.HalfPageUp.SetEnabled(false) + listKeyMap.Home.SetEnabled(false) + listKeyMap.End.SetEnabled(false) + + listKeyMap.DownOneItem = keyMap.Next + listKeyMap.UpOneItem = keyMap.Previous + + t := styles.CurrentTheme() + inputStyle := t.S().Base.Padding(0, 1, 0, 1) + modelList := list.New( + list.WithFilterable(true), + list.WithKeyMap(listKeyMap), + list.WithInputStyle(inputStyle), + list.WithWrapNavigation(true), + ) + help := help.New() + help.Styles = t.S().Help + + return &modelDialogCmp{ + modelList: modelList, + width: defaultWidth, + keyMap: DefaultKeyMap(), + help: help, + } +} + +var ProviderPopularity = map[models.ModelProvider]int{ + models.ProviderAnthropic: 1, + models.ProviderOpenAI: 2, + models.ProviderGemini: 3, + models.ProviderGROQ: 4, + models.ProviderOpenRouter: 5, + models.ProviderBedrock: 6, + models.ProviderAzure: 7, + models.ProviderVertexAI: 8, + models.ProviderXAI: 9, +} + +var ProviderName = map[models.ModelProvider]string{ + models.ProviderAnthropic: "Anthropic", + models.ProviderOpenAI: "OpenAI", + models.ProviderGemini: "Gemini", + models.ProviderGROQ: "Groq", + models.ProviderOpenRouter: "OpenRouter", + models.ProviderBedrock: "AWS Bedrock", + models.ProviderAzure: "Azure", + models.ProviderVertexAI: "VertexAI", + models.ProviderXAI: "xAI", +} + +func (m *modelDialogCmp) Init() tea.Cmd { + cfg := config.Get() + enabledProviders := getEnabledProviders(cfg) + + modelItems := []util.Model{} + for _, provider := range enabledProviders { + name, ok := ProviderName[provider] + if !ok { + name = string(provider) // Fallback to provider ID if name is not defined + } + modelItems = append(modelItems, commands.NewItemSection(name)) + for _, model := range getModelsForProvider(provider) { + modelItems = append(modelItems, completions.NewCompletionItem(model.Name, model)) + } + } + m.modelList.SetItems(modelItems) + return m.modelList.Init() +} + +func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.wWidth = msg.Width + m.wHeight = msg.Height + return m, m.modelList.SetSize(m.listWidth(), m.listHeight()) + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Select): + selectedItemInx := m.modelList.SelectedIndex() + if selectedItemInx == list.NoSelection { + return m, nil // No item selected, do nothing + } + items := m.modelList.Items() + selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(models.Model) + + return m, tea.Sequence( + util.CmdHandler(dialogs.CloseDialogMsg{}), + util.CmdHandler(ModelSelectedMsg{Model: selectedItem}), + ) + case key.Matches(msg, m.keyMap.Close): + return m, util.CmdHandler(dialogs.CloseDialogMsg{}) + default: + u, cmd := m.modelList.Update(msg) + m.modelList = u.(list.ListModel) + return m, cmd + } + } + return m, nil +} + +func (m *modelDialogCmp) View() tea.View { + t := styles.CurrentTheme() + listView := m.modelList.View() + content := lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-4)), + listView.String(), + "", + t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), + ) + v := tea.NewView(m.style().Render(content)) + if listView.Cursor() != nil { + c := m.moveCursor(listView.Cursor()) + v.SetCursor(c) + } + return v +} + +func (m *modelDialogCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(m.width). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) +} + +func (m *modelDialogCmp) listWidth() int { + return defaultWidth - 2 // 4 for padding +} + +func (m *modelDialogCmp) listHeight() int { + listHeigh := len(m.modelList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections + return min(listHeigh, m.wHeight/2) +} + +func GetSelectedModel(cfg *config.Config) models.Model { + agentCfg := cfg.Agents[config.AgentCoder] + selectedModelId := agentCfg.Model + return models.SupportedModels[selectedModelId] +} + +func getEnabledProviders(cfg *config.Config) []models.ModelProvider { + var providers []models.ModelProvider + for providerId, provider := range cfg.Providers { + if !provider.Disabled { + providers = append(providers, providerId) + } + } + + // Sort by provider popularity + slices.SortFunc(providers, func(a, b models.ModelProvider) int { + rA := ProviderPopularity[a] + rB := ProviderPopularity[b] + + // models not included in popularity ranking default to last + if rA == 0 { + rA = 999 + } + if rB == 0 { + rB = 999 + } + return rA - rB + }) + return providers +} + +func getModelsForProvider(provider models.ModelProvider) []models.Model { + var providerModels []models.Model + for _, model := range models.SupportedModels { + if model.Provider == provider { + providerModels = append(providerModels, model) + } + } + + // reverse alphabetical order (if llm naming was consistent latest would appear first) + slices.SortFunc(providerModels, func(a, b models.Model) int { + if a.Name > b.Name { + return -1 + } else if a.Name < b.Name { + return 1 + } + return 0 + }) + + return providerModels +} + +func (m *modelDialogCmp) Position() (int, int) { + row := m.wHeight/4 - 2 // just a bit above the center + col := m.wWidth / 2 + col -= m.width / 2 + return row, col +} + +func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { + row, col := m.Position() + offset := row + 3 // Border + title + cursor.Y += offset + cursor.X = cursor.X + col + 2 + return cursor +} + +func (m *modelDialogCmp) ID() dialogs.DialogID { + return ModelsDialogID +} diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..837deb74b4846e4592a61acde0a5dada706279dd --- /dev/null +++ b/internal/tui/components/dialogs/permissions/keys.go @@ -0,0 +1,70 @@ +package permissions + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + Left, + Right, + Tab, + Select, + Allow, + AllowSession, + Deny key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←", "previous"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→", "next"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch"), + ), + Allow: key.NewBinding( + key.WithKeys("a", "ctrl+a"), + key.WithHelp("a", "allow"), + ), + AllowSession: key.NewBinding( + key.WithKeys("s", "ctrl+s"), + key.WithHelp("s", "allow session"), + ), + Deny: key.NewBinding( + key.WithKeys("d", "ctrl+d"), + key.WithHelp("d", "deny"), + ), + Select: key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Allow, + k.AllowSession, + k.Deny, + k.Select, + } +} diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go new file mode 100644 index 0000000000000000000000000000000000000000..0e69eaccc89237bc6ca4bf7fe694a9f48b8d524c --- /dev/null +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -0,0 +1,473 @@ +package permissions + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/viewport" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/fileutil" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +type PermissionAction string + +// Permission responses +const ( + PermissionAllow PermissionAction = "allow" + PermissionAllowForSession PermissionAction = "allow_session" + PermissionDeny PermissionAction = "deny" + + PermissionsDialogID dialogs.DialogID = "permissions" +) + +// PermissionResponseMsg represents the user's response to a permission request +type PermissionResponseMsg struct { + Permission permission.PermissionRequest + Action PermissionAction +} + +// PermissionDialogCmp interface for permission dialog component +type PermissionDialogCmp interface { + dialogs.DialogModel +} + +// permissionDialogCmp is the implementation of PermissionDialog +type permissionDialogCmp struct { + wWidth int + wHeight int + width int + height int + permission permission.PermissionRequest + contentViewPort viewport.Model + selectedOption int // 0: Allow, 1: Allow for session, 2: Deny + + keyMap KeyMap +} + +func NewPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialogCmp { + // Create viewport for content + contentViewport := viewport.New() + return &permissionDialogCmp{ + contentViewPort: contentViewport, + selectedOption: 0, // Default to "Allow" + permission: permission, + keyMap: DefaultKeyMap(), + } +} + +func (p *permissionDialogCmp) Init() tea.Cmd { + return p.contentViewPort.Init() +} + +func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.wWidth = msg.Width + p.wHeight = msg.Height + cmd := p.SetSize() + cmds = append(cmds, cmd) + case tea.KeyPressMsg: + switch { + case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab): + p.selectedOption = (p.selectedOption + 1) % 3 + return p, nil + case key.Matches(msg, p.keyMap.Left): + p.selectedOption = (p.selectedOption + 2) % 3 + case key.Matches(msg, p.keyMap.Select): + return p, p.selectCurrentOption() + case key.Matches(msg, p.keyMap.Allow): + return p, tea.Batch( + util.CmdHandler(dialogs.CloseDialogMsg{}), + util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}), + ) + case key.Matches(msg, p.keyMap.AllowSession): + return p, tea.Batch( + util.CmdHandler(dialogs.CloseDialogMsg{}), + util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}), + ) + case key.Matches(msg, p.keyMap.Deny): + return p, tea.Batch( + util.CmdHandler(dialogs.CloseDialogMsg{}), + util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}), + ) + default: + // Pass other keys to viewport + viewPort, cmd := p.contentViewPort.Update(msg) + p.contentViewPort = viewPort + cmds = append(cmds, cmd) + } + } + + return p, tea.Batch(cmds...) +} + +func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd { + var action PermissionAction + + switch p.selectedOption { + case 0: + action = PermissionAllow + case 1: + action = PermissionAllowForSession + case 2: + action = PermissionDeny + } + + return tea.Batch( + util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}), + util.CmdHandler(dialogs.CloseDialogMsg{}), + ) +} + +func (p *permissionDialogCmp) renderButtons() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base + + buttons := []core.ButtonOpts{ + { + Text: "Allow", + UnderlineIndex: 0, // "A" + Selected: p.selectedOption == 0, + }, + { + Text: "Allow for Session", + UnderlineIndex: 10, // "S" in "Session" + Selected: p.selectedOption == 1, + }, + { + Text: "Deny", + UnderlineIndex: 0, // "D" + Selected: p.selectedOption == 2, + }, + } + + content := core.SelectableButtons(buttons, " ") + + return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content) +} + +func (p *permissionDialogCmp) renderHeader() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base + + toolKey := t.S().Muted.Render("Tool") + toolValue := t.S().Text. + Width(p.width - lipgloss.Width(toolKey)). + Render(fmt.Sprintf(" %s", p.permission.ToolName)) + + pathKey := t.S().Muted.Render("Path") + pathValue := t.S().Text. + Width(p.width - lipgloss.Width(pathKey)). + Render(fmt.Sprintf(" %s", fileutil.PrettyPath(p.permission.Path))) + + headerParts := []string{ + lipgloss.JoinHorizontal( + lipgloss.Left, + toolKey, + toolValue, + ), + baseStyle.Render(strings.Repeat(" ", p.width)), + lipgloss.JoinHorizontal( + lipgloss.Left, + pathKey, + pathValue, + ), + baseStyle.Render(strings.Repeat(" ", p.width)), + } + + // Add tool-specific header information + switch p.permission.ToolName { + case tools.BashToolName: + headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command")) + case tools.EditToolName: + params := p.permission.Params.(tools.EditPermissionsParams) + fileKey := t.S().Muted.Render("File") + filePath := t.S().Text. + Width(p.width - lipgloss.Width(fileKey)). + Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath))) + headerParts = append(headerParts, + lipgloss.JoinHorizontal( + lipgloss.Left, + fileKey, + filePath, + ), + baseStyle.Render(strings.Repeat(" ", p.width)), + ) + + case tools.WriteToolName: + params := p.permission.Params.(tools.WritePermissionsParams) + fileKey := t.S().Muted.Render("File") + filePath := t.S().Text. + Width(p.width - lipgloss.Width(fileKey)). + Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath))) + headerParts = append(headerParts, + lipgloss.JoinHorizontal( + lipgloss.Left, + fileKey, + filePath, + ), + baseStyle.Render(strings.Repeat(" ", p.width)), + ) + case tools.FetchToolName: + headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL")) + } + + return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...)) +} + +func (p *permissionDialogCmp) renderBashContent() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base.Background(t.BgSubtle) + if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok { + content := pr.Command + t := styles.CurrentTheme() + content = strings.TrimSpace(content) + content = "\n" + content + "\n" + lines := strings.Split(content, "\n") + + width := p.width - 4 + var out []string + for _, ln := range lines { + ln = " " + ln // left padding + if len(ln) > width { + ln = ansi.Truncate(ln, width, "…") + } + out = append(out, t.S().Muted. + Width(width). + Foreground(t.FgBase). + Background(t.BgSubtle). + Render(ln)) + } + + // Use the cache for markdown rendering + renderedContent := strings.Join(out, "\n") + finalContent := baseStyle. + Width(p.contentViewPort.Width()). + Render(renderedContent) + + contentHeight := min(p.height-9, lipgloss.Height(finalContent)) + p.contentViewPort.SetHeight(contentHeight) + p.contentViewPort.SetContent(finalContent) + return p.styleViewport() + } + return "" +} + +func (p *permissionDialogCmp) renderEditContent() string { + if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok { + diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) { + return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width())) + }) + + contentHeight := min(p.height-9, lipgloss.Height(diff)) + p.contentViewPort.SetHeight(contentHeight) + p.contentViewPort.SetContent(diff) + return p.styleViewport() + } + return "" +} + +func (p *permissionDialogCmp) renderPatchContent() string { + if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok { + diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) { + return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width())) + }) + + contentHeight := min(p.height-9, lipgloss.Height(diff)) + p.contentViewPort.SetHeight(contentHeight) + p.contentViewPort.SetContent(diff) + return p.styleViewport() + } + return "" +} + +func (p *permissionDialogCmp) renderWriteContent() string { + if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok { + // Use the cache for diff rendering + diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) { + return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width())) + }) + + contentHeight := min(p.height-9, lipgloss.Height(diff)) + p.contentViewPort.SetHeight(contentHeight) + p.contentViewPort.SetContent(diff) + return p.styleViewport() + } + return "" +} + +func (p *permissionDialogCmp) renderFetchContent() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base.Background(t.BgSubtle) + if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok { + content := fmt.Sprintf("```bash\n%s\n```", pr.URL) + + // Use the cache for markdown rendering + renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { + r := styles.GetMarkdownRenderer(p.width - 4) + s, err := r.Render(content) + return s, err + }) + + finalContent := baseStyle. + Width(p.contentViewPort.Width()). + Render(renderedContent) + + contentHeight := min(p.height-9, lipgloss.Height(finalContent)) + p.contentViewPort.SetHeight(contentHeight) + p.contentViewPort.SetContent(finalContent) + return p.styleViewport() + } + return "" +} + +func (p *permissionDialogCmp) renderDefaultContent() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base.Background(t.BgSubtle) + + content := p.permission.Description + + // Use the cache for markdown rendering + renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { + r := styles.GetMarkdownRenderer(p.width - 4) + s, err := r.Render(content) + return s, err + }) + + finalContent := baseStyle. + Width(p.contentViewPort.Width()). + Render(renderedContent) + p.contentViewPort.SetContent(finalContent) + + if renderedContent == "" { + return "" + } + + return p.styleViewport() +} + +func (p *permissionDialogCmp) styleViewport() string { + t := styles.CurrentTheme() + return t.S().Base.Render(p.contentViewPort.View()) +} + +func (p *permissionDialogCmp) render() string { + t := styles.CurrentTheme() + baseStyle := t.S().Base + title := core.Title("Permission Required", p.width-4) + // Render header + headerContent := p.renderHeader() + // Render buttons + buttons := p.renderButtons() + + p.contentViewPort.SetWidth(p.width - 4) + + // Render content based on tool type + var contentFinal string + switch p.permission.ToolName { + case tools.BashToolName: + contentFinal = p.renderBashContent() + case tools.EditToolName: + contentFinal = p.renderEditContent() + case tools.PatchToolName: + contentFinal = p.renderPatchContent() + case tools.WriteToolName: + contentFinal = p.renderWriteContent() + case tools.FetchToolName: + contentFinal = p.renderFetchContent() + default: + contentFinal = p.renderDefaultContent() + } + // Calculate content height dynamically based on window size + + content := lipgloss.JoinVertical( + lipgloss.Top, + title, + "", + headerContent, + contentFinal, + "", + buttons, + "", + ) + + return baseStyle. + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus). + Width(p.width). + Render( + content, + ) +} + +func (p *permissionDialogCmp) View() tea.View { + return tea.NewView(p.render()) +} + +func (p *permissionDialogCmp) SetSize() tea.Cmd { + if p.permission.ID == "" { + return nil + } + switch p.permission.ToolName { + case tools.BashToolName: + p.width = int(float64(p.wWidth) * 0.4) + p.height = int(float64(p.wHeight) * 0.3) + case tools.EditToolName: + p.width = int(float64(p.wWidth) * 0.8) + p.height = int(float64(p.wHeight) * 0.8) + case tools.WriteToolName: + p.width = int(float64(p.wWidth) * 0.8) + p.height = int(float64(p.wHeight) * 0.8) + case tools.FetchToolName: + p.width = int(float64(p.wWidth) * 0.4) + p.height = int(float64(p.wHeight) * 0.3) + default: + p.width = int(float64(p.wWidth) * 0.7) + p.height = int(float64(p.wHeight) * 0.5) + } + return nil +} + +func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string { + content, err := generator() + if err != nil { + return fmt.Sprintf("Error formatting diff: %v", err) + } + return content +} + +func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string { + content, err := generator() + if err != nil { + return fmt.Sprintf("Error rendering markdown: %v", err) + } + + return content +} + +// ID implements PermissionDialogCmp. +func (p *permissionDialogCmp) ID() dialogs.DialogID { + return PermissionsDialogID +} + +// Position implements PermissionDialogCmp. +func (p *permissionDialogCmp) Position() (int, int) { + row := (p.wHeight / 2) - 2 // Just a bit above the center + row -= p.height / 2 + col := p.wWidth / 2 + col -= p.width / 2 + return row, col +} diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..12773f1ad452963364546f21161361060845811c --- /dev/null +++ b/internal/tui/components/dialogs/quit/keys.go @@ -0,0 +1,64 @@ +package quit + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +// KeyMap defines the keyboard bindings for the quit dialog. +type KeyMap struct { + LeftRight, + EnterSpace, + Yes, + No, + Tab, + Close key.Binding +} + +func DefaultKeymap() KeyMap { + return KeyMap{ + LeftRight: key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "switch options"), + ), + EnterSpace: key.NewBinding( + key.WithKeys("enter", " "), + key.WithHelp("enter/space", "confirm"), + ), + Yes: key.NewBinding( + key.WithKeys("y", "Y", "ctrl+c"), + key.WithHelp("y/Y/ctrl+c", "yes"), + ), + No: key.NewBinding( + key.WithKeys("n", "N"), + key.WithHelp("n/N", "no"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch options"), + ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.LeftRight, + k.EnterSpace, + } +} diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go new file mode 100644 index 0000000000000000000000000000000000000000..9f57afac7d609212d82999aa2e57fb0c13ca5d28 --- /dev/null +++ b/internal/tui/components/dialogs/quit/quit.go @@ -0,0 +1,125 @@ +package quit + +import ( + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +const ( + question = "Are you sure you want to quit?" + QuitDialogID dialogs.DialogID = "quit" +) + +// QuitDialog represents a confirmation dialog for quitting the application. +type QuitDialog interface { + dialogs.DialogModel + layout.Bindings +} + +type quitDialogCmp struct { + wWidth int + wHeight int + + selectedNo bool // true if "No" button is selected + keymap KeyMap +} + +// NewQuitDialog creates a new quit confirmation dialog. +func NewQuitDialog() QuitDialog { + return &quitDialogCmp{ + selectedNo: true, // Default to "No" for safety + keymap: DefaultKeymap(), + } +} + +func (q *quitDialogCmp) Init() tea.Cmd { + return nil +} + +// Update handles keyboard input for the quit dialog. +func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + q.wWidth = msg.Width + q.wHeight = msg.Height + case tea.KeyPressMsg: + switch { + case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab): + q.selectedNo = !q.selectedNo + return q, nil + case key.Matches(msg, q.keymap.EnterSpace): + if !q.selectedNo { + return q, tea.Quit + } + return q, util.CmdHandler(dialogs.CloseDialogMsg{}) + case key.Matches(msg, q.keymap.Yes): + return q, tea.Quit + case key.Matches(msg, q.keymap.No, q.keymap.Close): + return q, util.CmdHandler(dialogs.CloseDialogMsg{}) + } + } + return q, nil +} + +// View renders the quit dialog with Yes/No buttons. +func (q *quitDialogCmp) View() tea.View { + t := styles.CurrentTheme() + baseStyle := t.S().Base + yesStyle := t.S().Text + noStyle := yesStyle + + if q.selectedNo { + noStyle = noStyle.Foreground(t.White).Background(t.Secondary) + yesStyle = yesStyle.Background(t.BgSubtle) + } else { + yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary) + noStyle = noStyle.Background(t.BgSubtle) + } + + yesButton := yesStyle.Padding(0, 1).Render("Yep!") + noButton := noStyle.Padding(0, 1).Render("Nope") + + buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render( + lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton), + ) + + content := baseStyle.Render( + lipgloss.JoinVertical( + lipgloss.Center, + question, + "", + buttons, + ), + ) + + quitDialogStyle := baseStyle. + Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) + + return tea.NewView( + quitDialogStyle.Render(content), + ) +} + +func (q *quitDialogCmp) BindingKeys() []key.Binding { + return layout.KeyMapToSlice(q.keymap) +} + +func (q *quitDialogCmp) Position() (int, int) { + row := q.wHeight / 2 + row -= 7 / 2 + col := q.wWidth / 2 + col -= (lipgloss.Width(question) + 4) / 2 + + return row, col +} + +func (q *quitDialogCmp) ID() dialogs.DialogID { + return QuitDialogID +} diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..0affd6a872251ae28a370c2bb62a007f0821be19 --- /dev/null +++ b/internal/tui/components/dialogs/sessions/keys.go @@ -0,0 +1,58 @@ +package sessions + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + Select, + Next, + Previous, + Close key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Select: key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Next: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ), + Previous: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding( + + key.WithKeys("down", "up"), + key.WithHelp("↑↓", "choose"), + ), + k.Select, + k.Close, + } +} diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go new file mode 100644 index 0000000000000000000000000000000000000000..37c7d12d8c846a83f4a778ca87cc404a51a065f3 --- /dev/null +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -0,0 +1,180 @@ +package sessions + +import ( + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" + "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +const SessionsDialogID dialogs.DialogID = "sessions" + +// SessionDialog interface for the session switching dialog +type SessionDialog interface { + dialogs.DialogModel +} + +type sessionDialogCmp struct { + selectedInx int + wWidth int + wHeight int + width int + selectedSessionID string + keyMap KeyMap + sessionsList list.ListModel + renderedSelected bool + help help.Model +} + +// NewSessionDialogCmp creates a new session switching dialog +func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog { + t := styles.CurrentTheme() + listKeyMap := list.DefaultKeyMap() + keyMap := DefaultKeyMap() + + listKeyMap.Down.SetEnabled(false) + listKeyMap.Up.SetEnabled(false) + listKeyMap.NDown.SetEnabled(false) + listKeyMap.NUp.SetEnabled(false) + listKeyMap.HalfPageDown.SetEnabled(false) + listKeyMap.HalfPageUp.SetEnabled(false) + listKeyMap.Home.SetEnabled(false) + listKeyMap.End.SetEnabled(false) + + listKeyMap.DownOneItem = keyMap.Next + listKeyMap.UpOneItem = keyMap.Previous + + selectedInx := 0 + items := make([]util.Model, len(sessions)) + if len(sessions) > 0 { + for i, session := range sessions { + items[i] = completions.NewCompletionItem(session.Title, session) + if session.ID == selectedID { + selectedInx = i + } + } + } + + sessionsList := list.New( + list.WithFilterable(true), + list.WithFilterPlaceholder("Enter a session name"), + list.WithKeyMap(listKeyMap), + list.WithItems(items), + list.WithWrapNavigation(true), + ) + help := help.New() + help.Styles = t.S().Help + s := &sessionDialogCmp{ + selectedInx: selectedInx, + selectedSessionID: selectedID, + keyMap: DefaultKeyMap(), + sessionsList: sessionsList, + help: help, + } + + return s +} + +func (s *sessionDialogCmp) Init() tea.Cmd { + return s.sessionsList.Init() +} + +func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.wWidth = msg.Width + s.wHeight = msg.Height + s.width = s.wWidth / 2 + var cmds []tea.Cmd + cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight())) + if !s.renderedSelected { + cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx)) + s.renderedSelected = true + } + return s, tea.Sequence(cmds...) + case tea.KeyPressMsg: + switch { + case key.Matches(msg, s.keyMap.Select): + if len(s.sessionsList.Items()) > 0 { + items := s.sessionsList.Items() + selectedItemInx := s.sessionsList.SelectedIndex() + return s, tea.Sequence( + util.CmdHandler(dialogs.CloseDialogMsg{}), + util.CmdHandler( + chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)), + ), + ) + } + case key.Matches(msg, s.keyMap.Close): + return s, util.CmdHandler(dialogs.CloseDialogMsg{}) + default: + u, cmd := s.sessionsList.Update(msg) + s.sessionsList = u.(list.ListModel) + return s, cmd + } + } + return s, nil +} + +func (s *sessionDialogCmp) View() tea.View { + t := styles.CurrentTheme() + listView := s.sessionsList.View() + content := lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)), + listView.String(), + "", + t.S().Base.Width(s.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(s.help.View(s.keyMap)), + ) + + v := tea.NewView(s.style().Render(content)) + if listView.Cursor() != nil { + c := s.moveCursor(listView.Cursor()) + v.SetCursor(c) + } + return v +} + +func (s *sessionDialogCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(s.width). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) +} + +func (s *sessionDialogCmp) listHeight() int { + return s.wHeight/2 - 6 // 5 for the border, title and help +} + +func (s *sessionDialogCmp) listWidth() int { + return s.width - 2 // 2 for the border +} + +func (s *sessionDialogCmp) Position() (int, int) { + row := s.wHeight/4 - 2 // just a bit above the center + col := s.wWidth / 2 + col -= s.width / 2 + return row, col +} + +func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { + row, col := s.Position() + offset := row + 3 // Border + title + cursor.Y += offset + cursor.X = cursor.X + col + 2 + return cursor +} + +// ID implements SessionDialog. +func (s *sessionDialogCmp) ID() dialogs.DialogID { + return SessionsDialogID +} diff --git a/internal/tui/components/image/image.go b/internal/tui/components/image/image.go new file mode 100644 index 0000000000000000000000000000000000000000..5d84c18e984c0e252064f2973263f9390118e244 --- /dev/null +++ b/internal/tui/components/image/image.go @@ -0,0 +1,86 @@ +// Based on the implementation by @trashhalo at: +// https://github.com/trashhalo/imgcat +package image + +import ( + "fmt" + _ "image/jpeg" + _ "image/png" + + tea "github.com/charmbracelet/bubbletea/v2" +) + +type Model struct { + url string + image string + width uint + height uint + err error +} + +func New(width, height uint, url string) Model { + return Model{ + width: width, + height: height, + url: url, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case errMsg: + m.err = msg + return m, nil + case redrawMsg: + m.width = msg.width + m.height = msg.height + m.url = msg.url + return m, loadURL(m.url) + case loadMsg: + return handleLoadMsg(m, msg) + } + return m, nil +} + +func (m Model) View() string { + if m.err != nil { + return fmt.Sprintf("couldn't load image(s): %v", m.err) + } + return m.image +} + +type errMsg struct{ error } + +func (m Model) Redraw(width uint, height uint, url string) tea.Cmd { + return func() tea.Msg { + return redrawMsg{ + width: width, + height: height, + url: url, + } + } +} + +func (m Model) UpdateURL(url string) tea.Cmd { + return func() tea.Msg { + return redrawMsg{ + width: m.width, + height: m.height, + url: url, + } + } +} + +type redrawMsg struct { + width uint + height uint + url string +} + +func (m Model) IsLoading() bool { + return m.image == "" +} diff --git a/internal/tui/components/image/load.go b/internal/tui/components/image/load.go new file mode 100644 index 0000000000000000000000000000000000000000..25fb4bc82908b4d818efab199356d8a5b9bfe87d --- /dev/null +++ b/internal/tui/components/image/load.go @@ -0,0 +1,146 @@ +// Based on the implementation by @trashhalo at: +// https://github.com/trashhalo/imgcat +package image + +import ( + "context" + "image" + "image/png" + "io" + "net/http" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/disintegration/imageorient" + "github.com/lucasb-eyer/go-colorful" + "github.com/muesli/termenv" + "github.com/nfnt/resize" + "github.com/srwiley/oksvg" + "github.com/srwiley/rasterx" +) + +type loadMsg struct { + io.ReadCloser +} + +func loadURL(url string) tea.Cmd { + var r io.ReadCloser + var err error + + if strings.HasPrefix(url, "http") { + var resp *http.Request + resp, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + r = resp.Body + } else { + r, err = os.Open(url) + } + + if err != nil { + return func() tea.Msg { + return errMsg{err} + } + } + + return load(r) +} + +func load(r io.ReadCloser) tea.Cmd { + return func() tea.Msg { + return loadMsg{r} + } +} + +func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) { + defer msg.Close() + + img, err := readerToImage(m.width, m.height, m.url, msg) + if err != nil { + return m, func() tea.Msg { return errMsg{err} } + } + m.image = img + return m, nil +} + +func imageToString(width, height uint, img image.Image) (string, error) { + img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3) + b := img.Bounds() + w := b.Max.X + h := b.Max.Y + p := termenv.ColorProfile() + str := strings.Builder{} + for y := 0; y < h; y += 2 { + for x := w; x < int(width); x = x + 2 { + str.WriteString(" ") + } + for x := range w { + c1, _ := colorful.MakeColor(img.At(x, y)) + color1 := p.Color(c1.Hex()) + c2, _ := colorful.MakeColor(img.At(x, y+1)) + color2 := p.Color(c2.Hex()) + str.WriteString(termenv.String("▀"). + Foreground(color1). + Background(color2). + String()) + } + str.WriteString("\n") + } + return str.String(), nil +} + +func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) { + if strings.HasSuffix(strings.ToLower(url), ".svg") { + return svgToImage(width, height, r) + } + + img, _, err := imageorient.Decode(r) + if err != nil { + return "", err + } + + return imageToString(width, height, img) +} + +func svgToImage(width uint, height uint, r io.Reader) (string, error) { + // Original author: https://stackoverflow.com/users/10826783/usual-human + // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang + // Adapted to use size from SVG, and to use temp file. + + tmpPngFile, err := os.CreateTemp("", "img.*.png") + if err != nil { + return "", err + } + tmpPngPath := tmpPngFile.Name() + defer os.Remove(tmpPngPath) + defer tmpPngFile.Close() + + // Rasterize the SVG: + icon, err := oksvg.ReadIconStream(r) + if err != nil { + return "", err + } + w := int(icon.ViewBox.W) + h := int(icon.ViewBox.H) + icon.SetTarget(0, 0, float64(w), float64(h)) + rgba := image.NewRGBA(image.Rect(0, 0, w, h)) + icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1) + // Write rasterized image as PNG: + err = png.Encode(tmpPngFile, rgba) + if err != nil { + tmpPngFile.Close() + return "", err + } + tmpPngFile.Close() + + rPng, err := os.Open(tmpPngPath) + if err != nil { + return "", err + } + defer rPng.Close() + + img, _, err := imageorient.Decode(rPng) + if err != nil { + return "", err + } + return imageToString(width, height, img) +} diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go new file mode 100644 index 0000000000000000000000000000000000000000..4b044c9dbd45284c72b7d03636d7399555e5f388 --- /dev/null +++ b/internal/tui/components/logo/logo.go @@ -0,0 +1,313 @@ +package logo + +import ( + "fmt" + "image/color" + "math/rand/v2" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/slice" +) + +// letterform represents a letterform. It can be stretched horizontally by +// a given amount via the boolean argument. +type letterform func(bool) string + +const diag = `╱` + +// Opts are the options for rendering the Crush title art. +type Opts struct { + FieldColor color.Color // diagonal lines + TitleColorA color.Color // left gradient ramp point + TitleColorB color.Color // right gradient ramp point + CharmColor color.Color // Charm™ text color + VersionColor color.Color // Version text color +} + +// Render renders the Crush logo. Set the argument to true to render the narrow +// version, intended for use in a sidebar. +// +// The compact argument determines whether it renders compact for the sidebar +// or wider for the main pane. +func Render(version string, compact bool, o Opts) string { + const charm = " Charm™" + + fg := func(c color.Color, s string) string { + return lipgloss.NewStyle().Foreground(c).Render(s) + } + + // Title. + crush := renderWord(1, !compact, letterC, letterR, letterU, letterS, letterH) + crushWidth := lipgloss.Width(crush) + b := new(strings.Builder) + for r := range strings.SplitSeq(crush, "\n") { + fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB)) + } + crush = b.String() + + // Charm and version. + metaRowGap := 1 + maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap + version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long. + gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version)) + metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version) + + // Join the meta row and big Crush title. + crush = strings.TrimSpace(metaRow + "\n" + crush) + + // Narrow version. + if compact { + field := fg(o.FieldColor, strings.Repeat(diag, crushWidth)) + return strings.Join([]string{field, field, crush, field, ""}, "\n") + } + + fieldHeight := lipgloss.Height(crush) + + // Left field. + const leftWidth = 6 + leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth)) + leftField := new(strings.Builder) + for range fieldHeight { + fmt.Fprintln(leftField, leftFieldRow) + } + + // Right field. + const rightWidth = 15 + const stepDownAt = 0 + rightField := new(strings.Builder) + for i := range fieldHeight { + width := rightWidth + if i >= stepDownAt { + width = rightWidth - (i - stepDownAt) + } + fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n") + } + + // Return the wide version. + const hGap = " " + return lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String()) +} + +// renderWord renders letterforms to fork a word. +func renderWord(spacing int, stretchRandomLetter bool, letterforms ...letterform) string { + if spacing < 0 { + spacing = 0 + } + + renderedLetterforms := make([]string, len(letterforms)) + + // pick one letter randomly to stretch + stretchIndex := -1 + if stretchRandomLetter { + stretchIndex = rand.IntN(len(letterforms)) //nolint:gosec + } + + for i, letter := range letterforms { + renderedLetterforms[i] = letter(i == stretchIndex) + } + + if spacing > 0 { + // Add spaces between the letters and render. + renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing)) + } + return strings.TrimSpace( + lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...), + ) +} + +// letterC renders the letter C in a stylized way. It takes an integer that +// determines how many cells to stretch the letter. If the stretch is less than +// 1, it defaults to no stretching. +func letterC(stretch bool) string { + // Here's what we're making: + // + // ▄▀▀▀▀ + // █ + // ▀▀▀▀ + + left := heredoc.Doc(` + ▄ + █ + `) + right := heredoc.Doc(` + ▀ + + ▀ + `) + return joinLetterform( + left, + stretchLetterformPart(right, letterformProps{ + stretch: stretch, + width: 4, + minStretch: 7, + maxStretch: 12, + }), + ) +} + +// letterH renders the letter H in a stylized way. It takes an integer that +// determines how many cells to stretch the letter. If the stretch is less than +// 1, it defaults to no stretching. +func letterH(stretch bool) string { + // Here's what we're making: + // + // █ █ + // █▀▀▀█ + // ▀ ▀ + + side := heredoc.Doc(` + █ + █ + ▀`) + middle := heredoc.Doc(` + + ▀ + `) + return joinLetterform( + side, + stretchLetterformPart(middle, letterformProps{ + stretch: stretch, + width: 3, + minStretch: 8, + maxStretch: 12, + }), + side, + ) +} + +// letterR renders the letter R in a stylized way. It takes an integer that +// determines how many cells to stretch the letter. If the stretch is less than +// 1, it defaults to no stretching. +func letterR(stretch bool) string { + // Here's what we're making: + // + // █▀▀▀▄ + // █▀▀▀▄ + // ▀ ▀ + + left := heredoc.Doc(` + █ + █ + ▀ + `) + center := heredoc.Doc(` + ▀ + ▀ + `) + right := heredoc.Doc(` + ▄ + ▄ + ▀ + `) + return joinLetterform( + left, + stretchLetterformPart(center, letterformProps{ + stretch: stretch, + width: 3, + minStretch: 7, + maxStretch: 12, + }), + right, + ) +} + +// letterS renders the letter S in a stylized way. It takes an integer that +// determines how many cells to stretch the letter. If the stretch is less than +// 1, it defaults to no stretching. +func letterS(stretch bool) string { + // Here's what we're making: + // + // ▄▀▀▀▀ + // ▀▀▀▄ + // ▀▀▀▀ + + left := heredoc.Doc(` + ▄ + + ▀ + `) + center := heredoc.Doc(` + ▀ + ▀ + ▀ + `) + right := heredoc.Doc(` + ▀ + ▄ + `) + return joinLetterform( + left, + stretchLetterformPart(center, letterformProps{ + stretch: stretch, + width: 3, + minStretch: 7, + maxStretch: 12, + }), + right, + ) +} + +// letterU renders the letter U in a stylized way. It takes an integer that +// determines how many cells to stretch the letter. If the stretch is less than +// 1, it defaults to no stretching. +func letterU(stretch bool) string { + // Here's what we're making: + // + // █ █ + // █ █ + // ▀▀▀ + + side := heredoc.Doc(` + █ + █ + `) + middle := heredoc.Doc(` + + + ▀ + `) + return joinLetterform( + side, + stretchLetterformPart(middle, letterformProps{ + stretch: stretch, + width: 3, + minStretch: 7, + maxStretch: 12, + }), + side, + ) +} + +func joinLetterform(letters ...string) string { + return lipgloss.JoinHorizontal(lipgloss.Top, letters...) +} + +// letterformProps defines letterform stretching properties. +// for readability. +type letterformProps struct { + width int + minStretch int + maxStretch int + stretch bool +} + +// stretchLetterformPart is a helper function for letter stretching. If randomize +// is false the minimum number will be used. +func stretchLetterformPart(s string, p letterformProps) string { + if p.maxStretch < p.minStretch { + p.minStretch, p.maxStretch = p.maxStretch, p.minStretch + } + n := p.width + if p.stretch { + n = rand.IntN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec + } + parts := make([]string, n) + for i := range parts { + parts[i] = s + } + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) +} diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go index 9d7713bbf048beea68dc0d007af948df2b2a6127..82bec5a32fb840624f63cc326672174f2c8b0d4f 100644 --- a/internal/tui/components/logs/details.go +++ b/internal/tui/components/logs/details.go @@ -5,18 +5,18 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/viewport" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" ) type DetailComponent interface { - tea.Model + util.Model layout.Sizeable layout.Bindings } @@ -50,45 +50,57 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (i *detailCmp) updateContent() { var content strings.Builder - t := theme.CurrentTheme() + t := styles.CurrentTheme() - // Format the header with timestamp and level - timeStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) + if i.currentLog.ID == "" { + content.WriteString(t.S().Muted.Render("No log selected")) + i.viewport.SetContent(content.String()) + return + } + + // Level badge with background color levelStyle := getLevelStyle(i.currentLog.Level) + levelBadge := levelStyle.Padding(0, 1).Render(strings.ToUpper(i.currentLog.Level)) + + // Timestamp with relative time + timeStr := i.currentLog.Time.Format("2006-01-05 15:04:05 UTC") + relativeTime := getRelativeTime(i.currentLog.Time) + timeStyle := t.S().Muted + // Header line header := lipgloss.JoinHorizontal( - lipgloss.Center, - timeStyle.Render(i.currentLog.Time.Format(time.RFC3339)), - " ", - levelStyle.Render(i.currentLog.Level), + lipgloss.Left, + timeStr, + " ", + timeStyle.Render(relativeTime), ) - content.WriteString(lipgloss.NewStyle().Bold(true).Render(header)) + content.WriteString(levelBadge) + content.WriteString("\n\n") + content.WriteString(header) content.WriteString("\n\n") - // Message with styling - messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text()) - content.WriteString(messageStyle.Render("Message:")) + // Message section + messageHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true) + content.WriteString(messageHeaderStyle.Render("Message")) content.WriteString("\n") - content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message)) + content.WriteString(i.currentLog.Message) content.WriteString("\n\n") // Attributes section if len(i.currentLog.Attributes) > 0 { - attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text()) - content.WriteString(attrHeaderStyle.Render("Attributes:")) + attrHeaderStyle := t.S().Base.Foreground(t.Blue).Bold(true) + content.WriteString(attrHeaderStyle.Render("Attributes")) content.WriteString("\n") - // Create a table-like display for attributes - keyStyle := lipgloss.NewStyle().Foreground(t.Primary()).Bold(true) - valueStyle := lipgloss.NewStyle().Foreground(t.Text()) - for _, attr := range i.currentLog.Attributes { + keyStyle := t.S().Base.Foreground(t.Accent) + valueStyle := t.S().Text attrLine := fmt.Sprintf("%s: %s", keyStyle.Render(attr.Key), valueStyle.Render(attr.Value), ) - content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine)) + content.WriteString(attrLine) content.WriteString("\n") } } @@ -97,26 +109,53 @@ func (i *detailCmp) updateContent() { } func getLevelStyle(level string) lipgloss.Style { - style := lipgloss.NewStyle().Bold(true) - t := theme.CurrentTheme() - + t := styles.CurrentTheme() + style := t.S().Base.Bold(true) + switch strings.ToLower(level) { case "info": - return style.Foreground(t.Info()) + return style.Foreground(t.White).Background(t.Info) case "warn", "warning": - return style.Foreground(t.Warning()) + return style.Foreground(t.White).Background(t.Warning) case "error", "err": - return style.Foreground(t.Error()) + return style.Foreground(t.White).Background(t.Error) case "debug": - return style.Foreground(t.Success()) + return style.Foreground(t.White).Background(t.Success) + case "fatal": + return style.Foreground(t.White).Background(t.Error) default: - return style.Foreground(t.Text()) + return style.Foreground(t.FgBase) + } +} + +func getRelativeTime(logTime time.Time) string { + now := time.Now() + diff := now.Sub(logTime) + + if diff < time.Minute { + return fmt.Sprintf("%ds ago", int(diff.Seconds())) + } else if diff < time.Hour { + return fmt.Sprintf("%dm ago", int(diff.Minutes())) + } else if diff < 24*time.Hour { + return fmt.Sprintf("%dh ago", int(diff.Hours())) + } else if diff < 30*24*time.Hour { + return fmt.Sprintf("%dd ago", int(diff.Hours()/24)) + } else if diff < 365*24*time.Hour { + return fmt.Sprintf("%dmo ago", int(diff.Hours()/(24*30))) + } else { + return fmt.Sprintf("%dy ago", int(diff.Hours()/(24*365))) } } -func (i *detailCmp) View() string { - t := theme.CurrentTheme() - return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), t.Background()) +func (i *detailCmp) View() tea.View { + t := styles.CurrentTheme() + style := t.S().Base. + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus). + Width(i.width - 2). // Adjust width for border + Height(i.height - 2). // Adjust height for border + Padding(1) + return tea.NewView(style.Render(i.viewport.View())) } func (i *detailCmp) GetSize() (int, int) { @@ -124,10 +163,11 @@ func (i *detailCmp) GetSize() (int, int) { } func (i *detailCmp) SetSize(width int, height int) tea.Cmd { + logging.Info("Setting size for detail component", "width", width, "height", height) i.width = width i.height = height - i.viewport.Width = i.width - i.viewport.Height = i.height + i.viewport.SetWidth(i.width - 4) + i.viewport.SetHeight(i.height - 4) i.updateContent() return nil } @@ -138,6 +178,6 @@ func (i *detailCmp) BindingKeys() []key.Binding { func NewLogsDetails() DetailComponent { return &detailCmp{ - viewport: viewport.New(0, 0), + viewport: viewport.New(), } } diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go index 8d59f967f0a53a80133a3c5d3b7d0e6785bda96e..d7671ba84304e971134472a78576605454a214c4 100644 --- a/internal/tui/components/logs/table.go +++ b/internal/tui/components/logs/table.go @@ -1,72 +1,105 @@ package logs import ( - "encoding/json" + "fmt" "slices" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/pubsub" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/table" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" ) type TableComponent interface { - tea.Model + util.Model layout.Sizeable layout.Bindings } type tableCmp struct { table table.Model + logs []logging.LogMessage } type selectedLogMsg logging.LogMessage func (i *tableCmp) Init() tea.Cmd { + i.logs = logging.List() i.setRows() return nil } func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - switch msg.(type) { + switch msg := msg.(type) { case pubsub.Event[logging.LogMessage]: - i.setRows() - return i, nil + return i, func() tea.Msg { + if msg.Type == pubsub.CreatedEvent { + rows := i.table.Rows() + for _, row := range rows { + if row[1] == msg.Payload.ID { + return nil // If the log already exists, do not add it again + } + } + i.logs = append(i.logs, msg.Payload) + i.table.SetRows( + append( + []table.Row{ + logToRow(msg.Payload), + }, + i.table.Rows()..., + ), + ) + } + return selectedLogMsg(msg.Payload) + } } - prevSelectedRow := i.table.SelectedRow() t, cmd := i.table.Update(msg) cmds = append(cmds, cmd) i.table = t - selectedRow := i.table.SelectedRow() - if selectedRow != nil { - if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] { - var log logging.LogMessage - for _, row := range logging.List() { - if row.ID == selectedRow[0] { - log = row - break - } - } - if log.ID != "" { - cmds = append(cmds, util.CmdHandler(selectedLogMsg(log))) + + cmds = append(cmds, func() tea.Msg { + for _, log := range logging.List() { + if log.ID == i.table.SelectedRow()[1] { + // If the selected row matches the log ID, return the selected log message + return selectedLogMsg(log) } } - } + return nil + }) return i, tea.Batch(cmds...) } -func (i *tableCmp) View() string { - t := theme.CurrentTheme() +func (i *tableCmp) View() tea.View { + t := styles.CurrentTheme() defaultStyles := table.DefaultStyles() - defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary()) + + // Header styling + defaultStyles.Header = defaultStyles.Header. + Foreground(t.Primary). + Bold(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(t.Border) + + // Selected row styling + defaultStyles.Selected = defaultStyles.Selected. + Foreground(t.FgSelected). + Background(t.Primary). + Bold(false) + + // Cell styling + defaultStyles.Cell = defaultStyles.Cell. + Foreground(t.FgBase) + i.table.SetStyles(defaultStyles) - return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), t.Background()) + return tea.NewView(i.table.View()) } func (i *tableCmp) GetSize() (int, int) { @@ -76,12 +109,30 @@ func (i *tableCmp) GetSize() (int, int) { func (i *tableCmp) SetSize(width int, height int) tea.Cmd { i.table.SetWidth(width) i.table.SetHeight(height) - cloumns := i.table.Columns() - for i, col := range cloumns { - col.Width = (width / len(cloumns)) - 2 - cloumns[i] = col - } - i.table.SetColumns(cloumns) + + columnWidth := (width - 10) / 4 + i.table.SetColumns([]table.Column{ + { + Title: "Level", + Width: 10, + }, + { + Title: "ID", + Width: columnWidth, + }, + { + Title: "Time", + Width: columnWidth, + }, + { + Title: "Message", + Width: columnWidth, + }, + { + Title: "Attributes", + Width: columnWidth, + }, + }) return nil } @@ -92,39 +143,54 @@ func (i *tableCmp) BindingKeys() []key.Binding { func (i *tableCmp) setRows() { rows := []table.Row{} - logs := logging.List() - slices.SortFunc(logs, func(a, b logging.LogMessage) int { + slices.SortFunc(i.logs, func(a, b logging.LogMessage) int { if a.Time.Before(b.Time) { - return 1 + return -1 } if a.Time.After(b.Time) { - return -1 + return 1 } return 0 }) - for _, log := range logs { - bm, _ := json.Marshal(log.Attributes) + for _, log := range i.logs { + rows = append(rows, logToRow(log)) + } + i.table.SetRows(rows) +} - row := table.Row{ - log.ID, - log.Time.Format("15:04:05"), - log.Level, - log.Message, - string(bm), +func logToRow(log logging.LogMessage) table.Row { + // Format attributes as JSON string + var attrStr string + if len(log.Attributes) > 0 { + var parts []string + for _, attr := range log.Attributes { + parts = append(parts, fmt.Sprintf(`{"Key":"%s","Value":"%s"}`, attr.Key, attr.Value)) } - rows = append(rows, row) + attrStr = "[" + strings.Join(parts, ",") + "]" + } + + // Format time with relative time + timeStr := log.Time.Format("2006-01-05 15:04:05 UTC") + relativeTime := getRelativeTime(log.Time) + fullTimeStr := timeStr + " " + relativeTime + + return table.Row{ + strings.ToUpper(log.Level), + log.ID, + fullTimeStr, + log.Message, + attrStr, } - i.table.SetRows(rows) } func NewLogsTable() TableComponent { columns := []table.Column{ - {Title: "ID", Width: 4}, - {Title: "Time", Width: 4}, - {Title: "Level", Width: 10}, - {Title: "Message", Width: 10}, - {Title: "Attributes", Width: 10}, + {Title: "Level"}, + {Title: "ID"}, + {Title: "Time"}, + {Title: "Message"}, + {Title: "Attributes"}, } tableModel := table.New( diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go deleted file mode 100644 index 7aad2494c6f93f084d52c3e11fa80d5b795ca217..0000000000000000000000000000000000000000 --- a/internal/tui/components/util/simple-list.go +++ /dev/null @@ -1,159 +0,0 @@ -package utilComponents - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" -) - -type SimpleListItem interface { - Render(selected bool, width int) string -} - -type SimpleList[T SimpleListItem] interface { - tea.Model - layout.Bindings - SetMaxWidth(maxWidth int) - GetSelectedItem() (item T, idx int) - SetItems(items []T) - GetItems() []T -} - -type simpleListCmp[T SimpleListItem] struct { - fallbackMsg string - items []T - selectedIdx int - maxWidth int - maxVisibleItems int - useAlphaNumericKeys bool - width int - height int -} - -type simpleListKeyMap struct { - Up key.Binding - Down key.Binding - UpAlpha key.Binding - DownAlpha key.Binding -} - -var simpleListKeys = simpleListKeyMap{ - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "previous list item"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "next list item"), - ), - UpAlpha: key.NewBinding( - key.WithKeys("k"), - key.WithHelp("k", "previous list item"), - ), - DownAlpha: key.NewBinding( - key.WithKeys("j"), - key.WithHelp("j", "next list item"), - ), -} - -func (c *simpleListCmp[T]) Init() tea.Cmd { - return nil -} - -func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)): - if c.selectedIdx > 0 { - c.selectedIdx-- - } - return c, nil - case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)): - if c.selectedIdx < len(c.items)-1 { - c.selectedIdx++ - } - return c, nil - } - } - - return c, nil -} - -func (c *simpleListCmp[T]) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(simpleListKeys) -} - -func (c *simpleListCmp[T]) GetSelectedItem() (T, int) { - if len(c.items) > 0 { - return c.items[c.selectedIdx], c.selectedIdx - } - - var zero T - return zero, -1 -} - -func (c *simpleListCmp[T]) SetItems(items []T) { - c.selectedIdx = 0 - c.items = items -} - -func (c *simpleListCmp[T]) GetItems() []T { - return c.items -} - -func (c *simpleListCmp[T]) SetMaxWidth(width int) { - c.maxWidth = width -} - -func (c *simpleListCmp[T]) View() string { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - items := c.items - maxWidth := c.maxWidth - maxVisibleItems := min(c.maxVisibleItems, len(items)) - startIdx := 0 - - if len(items) <= 0 { - return baseStyle. - Background(t.Background()). - Padding(0, 1). - Width(maxWidth). - Render(c.fallbackMsg) - } - - if len(items) > maxVisibleItems { - halfVisible := maxVisibleItems / 2 - if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible { - startIdx = c.selectedIdx - halfVisible - } else if c.selectedIdx >= len(items)-halfVisible { - startIdx = len(items) - maxVisibleItems - } - } - - endIdx := min(startIdx+maxVisibleItems, len(items)) - - listItems := make([]string, 0, maxVisibleItems) - - for i := startIdx; i < endIdx; i++ { - item := items[i] - title := item.Render(i == c.selectedIdx, maxWidth) - listItems = append(listItems, title) - } - - return lipgloss.JoinVertical(lipgloss.Left, listItems...) -} - -func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] { - return &simpleListCmp[T]{ - fallbackMsg: fallbackMsg, - items: items, - maxVisibleItems: maxVisibleItems, - useAlphaNumericKeys: useAlphaNumericKeys, - selectedIdx: 0, - } -} diff --git a/internal/tui/image/images.go b/internal/tui/image/images.go deleted file mode 100644 index d10a169fd5d46d37b75a66fe6ad17f1bb0c284f2..0000000000000000000000000000000000000000 --- a/internal/tui/image/images.go +++ /dev/null @@ -1,72 +0,0 @@ -package image - -import ( - "fmt" - "image" - "os" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/disintegration/imaging" - "github.com/lucasb-eyer/go-colorful" -) - -func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) { - fileInfo, err := os.Stat(filePath) - if err != nil { - return false, fmt.Errorf("error getting file info: %w", err) - } - - if fileInfo.Size() > sizeLimit { - return true, nil - } - - return false, nil -} - -func ToString(width int, img image.Image) string { - img = imaging.Resize(img, width, 0, imaging.Lanczos) - b := img.Bounds() - imageWidth := b.Max.X - h := b.Max.Y - str := strings.Builder{} - - for heightCounter := 0; heightCounter < h; heightCounter += 2 { - for x := range imageWidth { - c1, _ := colorful.MakeColor(img.At(x, heightCounter)) - color1 := lipgloss.Color(c1.Hex()) - - var color2 lipgloss.Color - if heightCounter+1 < h { - c2, _ := colorful.MakeColor(img.At(x, heightCounter+1)) - color2 = lipgloss.Color(c2.Hex()) - } else { - color2 = color1 - } - - str.WriteString(lipgloss.NewStyle().Foreground(color1). - Background(color2).Render("▀")) - } - - str.WriteString("\n") - } - - return str.String() -} - -func ImagePreview(width int, filename string) (string, error) { - imageContent, err := os.Open(filename) - if err != nil { - return "", err - } - defer imageContent.Close() - - img, _, err := image.Decode(imageContent) - if err != nil { - return "", err - } - - imageString := ToString(width, img) - - return imageString, nil -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..f41d5d4f328de0f2020226a5f146e7dc8a8dcaca --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,56 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + Logs key.Binding + Quit key.Binding + Help key.Binding + Commands key.Binding + Sessions key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Logs: key.NewBinding( + key.WithKeys("ctrl+l"), + key.WithHelp("ctrl+l", "logs"), + ), + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + + Help: key.NewBinding( + key.WithKeys("ctrl+_"), + key.WithHelp("ctrl+?", "toggle help"), + ), + Commands: key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "commands"), + ), + Sessions: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{} +} diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go index 83aef587938cc1f7ed56690bab98e10c9351b350..da13516250c57488221d7696c8fadceec15400a3 100644 --- a/internal/tui/layout/container.go +++ b/internal/tui/layout/container.go @@ -1,22 +1,28 @@ package layout import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" ) type Container interface { - tea.Model + util.Model Sizeable Bindings + Positionable + Focusable } type container struct { - width int - height int + width int + height int + isFocused bool - content tea.Model + x, y int + + content util.Model // Style options paddingTop int @@ -31,23 +37,47 @@ type container struct { borderStyle lipgloss.Border } +type ContainerOption func(*container) + +func NewContainer(content util.Model, options ...ContainerOption) Container { + c := &container{ + content: content, + borderStyle: lipgloss.NormalBorder(), + } + + for _, option := range options { + option(c) + } + + return c +} + func (c *container) Init() tea.Cmd { return c.content.Init() } func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - u, cmd := c.content.Update(msg) - c.content = u - return c, cmd + switch msg := msg.(type) { + case tea.KeyPressMsg: + if c.IsFocused() { + u, cmd := c.content.Update(msg) + c.content = u.(util.Model) + return c, cmd + } + return c, nil + default: + u, cmd := c.content.Update(msg) + c.content = u.(util.Model) + return c, cmd + } } -func (c *container) View() string { - t := theme.CurrentTheme() - style := lipgloss.NewStyle() +func (c *container) View() tea.View { + t := styles.CurrentTheme() width := c.width height := c.height - style = style.Background(t.Background()) + style := t.S().Base // Apply border if any side is enabled if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft { @@ -65,7 +95,7 @@ func (c *container) View() string { width-- } style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft) - style = style.BorderBackground(t.Background()).BorderForeground(t.BorderNormal()) + style = style.BorderBackground(t.BgBase).BorderForeground(t.Border) } style = style. Width(width). @@ -75,7 +105,11 @@ func (c *container) View() string { PaddingBottom(c.paddingBottom). PaddingLeft(c.paddingLeft) - return style.Render(c.content.View()) + contentView := c.content.View() + view := tea.NewView(style.Render(contentView.String())) + cursor := contentView.Cursor() + view.SetCursor(cursor) + return view } func (c *container) SetSize(width, height int) tea.Cmd { @@ -114,6 +148,15 @@ func (c *container) GetSize() (int, int) { return c.width, c.height } +func (c *container) SetPosition(x, y int) tea.Cmd { + c.x = x + c.y = y + if positionable, ok := c.content.(Positionable); ok { + return positionable.SetPosition(x, y) + } + return nil +} + func (c *container) BindingKeys() []key.Binding { if b, ok := c.content.(Bindings); ok { return b.BindingKeys() @@ -121,20 +164,31 @@ func (c *container) BindingKeys() []key.Binding { return []key.Binding{} } -type ContainerOption func(*container) - -func NewContainer(content tea.Model, options ...ContainerOption) Container { - - c := &container{ - content: content, - borderStyle: lipgloss.NormalBorder(), +// Blur implements Container. +func (c *container) Blur() tea.Cmd { + c.isFocused = false + if focusable, ok := c.content.(Focusable); ok { + return focusable.Blur() } + return nil +} - for _, option := range options { - option(c) +// Focus implements Container. +func (c *container) Focus() tea.Cmd { + c.isFocused = true + if focusable, ok := c.content.(Focusable); ok { + return focusable.Focus() } + return nil +} - return c +// IsFocused implements Container. +func (c *container) IsFocused() bool { + isFocused := c.isFocused + if focusable, ok := c.content.(Focusable); ok { + isFocused = isFocused || focusable.IsFocused() + } + return isFocused } // Padding options diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go index 495a3fbc5140917b35c342e96672aa4dd8ee4b18..2213c7288a94a43ba5d2d3769752243ad081c734 100644 --- a/internal/tui/layout/layout.go +++ b/internal/tui/layout/layout.go @@ -3,8 +3,9 @@ package layout import ( "reflect" - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" ) type Focusable interface { @@ -22,6 +23,14 @@ type Bindings interface { BindingKeys() []key.Binding } +type Positionable interface { + SetPosition(x, y int) tea.Cmd +} + +type Help interface { + Help() help.KeyMap +} + func KeyMapToSlice(t any) (bindings []key.Binding) { typ := reflect.TypeOf(t) if typ.Kind() != reflect.Struct { diff --git a/internal/tui/layout/overlay.go b/internal/tui/layout/overlay.go deleted file mode 100644 index 3a14dbc5eeb9d9245dfd1b1c6a4ddda7f791b5f1..0000000000000000000000000000000000000000 --- a/internal/tui/layout/overlay.go +++ /dev/null @@ -1,169 +0,0 @@ -package layout - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - chAnsi "github.com/charmbracelet/x/ansi" - "github.com/muesli/ansi" - "github.com/muesli/reflow/truncate" - "github.com/muesli/termenv" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// Most of this code is borrowed from -// https://github.com/charmbracelet/lipgloss/pull/102 -// as well as the lipgloss library, with some modification for what I needed. - -// Split a string into lines, additionally returning the size of the widest -// line. -func getLines(s string) (lines []string, widest int) { - lines = strings.Split(s, "\n") - - for _, l := range lines { - w := ansi.PrintableRuneWidth(l) - if widest < w { - widest = w - } - } - - return lines, widest -} - -// PlaceOverlay places fg on top of bg. -func PlaceOverlay( - x, y int, - fg, bg string, - shadow bool, opts ...WhitespaceOption, -) string { - fgLines, fgWidth := getLines(fg) - bgLines, bgWidth := getLines(bg) - bgHeight := len(bgLines) - fgHeight := len(fgLines) - - if shadow { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - var shadowbg string = "" - shadowchar := lipgloss.NewStyle(). - Background(t.BackgroundDarker()). - Foreground(t.Background()). - Render("░") - bgchar := baseStyle.Render(" ") - for i := 0; i <= fgHeight; i++ { - if i == 0 { - shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n" - } else { - shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n" - } - } - - fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...) - fgLines, fgWidth = getLines(fg) - fgHeight = len(fgLines) - } - - if fgWidth >= bgWidth && fgHeight >= bgHeight { - // FIXME: return fg or bg? - return fg - } - // TODO: allow placement outside of the bg box? - x = util.Clamp(x, 0, bgWidth-fgWidth) - y = util.Clamp(y, 0, bgHeight-fgHeight) - - ws := &whitespace{} - for _, opt := range opts { - opt(ws) - } - - var b strings.Builder - for i, bgLine := range bgLines { - if i > 0 { - b.WriteByte('\n') - } - if i < y || i >= y+fgHeight { - b.WriteString(bgLine) - continue - } - - pos := 0 - if x > 0 { - left := truncate.String(bgLine, uint(x)) - pos = ansi.PrintableRuneWidth(left) - b.WriteString(left) - if pos < x { - b.WriteString(ws.render(x - pos)) - pos = x - } - } - - fgLine := fgLines[i-y] - b.WriteString(fgLine) - pos += ansi.PrintableRuneWidth(fgLine) - - right := cutLeft(bgLine, pos) - bgWidth := ansi.PrintableRuneWidth(bgLine) - rightWidth := ansi.PrintableRuneWidth(right) - if rightWidth <= bgWidth-pos { - b.WriteString(ws.render(bgWidth - rightWidth - pos)) - } - - b.WriteString(right) - } - - return b.String() -} - -// cutLeft cuts printable characters from the left. -// This function is heavily based on muesli's ansi and truncate packages. -func cutLeft(s string, cutWidth int) string { - return chAnsi.Cut(s, cutWidth, lipgloss.Width(s)) -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} - -type whitespace struct { - style termenv.Style - chars string -} - -// Render whitespaces. -func (w whitespace) render(width int) string { - if w.chars == "" { - w.chars = " " - } - - r := []rune(w.chars) - j := 0 - b := strings.Builder{} - - // Cycle through runes and print them into the whitespace. - for i := 0; i < width; { - b.WriteRune(r[j]) - j++ - if j >= len(r) { - j = 0 - } - i += ansi.PrintableRuneWidth(string(r[j])) - } - - // Fill any extra gaps white spaces. This might be necessary if any runes - // are more than one cell wide, which could leave a one-rune gap. - short := width - ansi.PrintableRuneWidth(b.String()) - if short > 0 { - b.WriteString(strings.Repeat(" ", short)) - } - - return w.style.Styled(b.String()) -} - -// WhitespaceOption sets a styling rule for rendering whitespace. -type WhitespaceOption func(*whitespace) diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go index 2684a8447cbe4fec4e3d389cf148cec622bfd72b..98b656aa0661a1199e532cebbf97681f1790b723 100644 --- a/internal/tui/layout/split.go +++ b/internal/tui/layout/split.go @@ -1,14 +1,23 @@ package layout import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +type LayoutPanel string + +const ( + LeftPanel LayoutPanel = "left" + RightPanel LayoutPanel = "right" + BottomPanel LayoutPanel = "bottom" ) type SplitPaneLayout interface { - tea.Model + util.Model Sizeable Bindings SetLeftPanel(panel Container) tea.Cmd @@ -18,17 +27,23 @@ type SplitPaneLayout interface { ClearLeftPanel() tea.Cmd ClearRightPanel() tea.Cmd ClearBottomPanel() tea.Cmd + + FocusPanel(panel LayoutPanel) tea.Cmd } type splitPaneLayout struct { - width int - height int + width int + height int + ratio float64 verticalRatio float64 rightPanel Container leftPanel Container bottomPanel Container + + fixedBottomHeight int // Fixed height for the bottom panel, if any + fixedRightWidth int // Fixed width for the right panel, if any } type SplitPaneOption func(*splitPaneLayout) @@ -85,17 +100,17 @@ func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, tea.Batch(cmds...) } -func (s *splitPaneLayout) View() string { +func (s *splitPaneLayout) View() tea.View { var topSection string if s.leftPanel != nil && s.rightPanel != nil { leftView := s.leftPanel.View() rightView := s.rightPanel.View() - topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView) + topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView.String(), rightView.String()) } else if s.leftPanel != nil { - topSection = s.leftPanel.View() + topSection = s.leftPanel.View().String() } else if s.rightPanel != nil { - topSection = s.rightPanel.View() + topSection = s.rightPanel.View().String() } else { topSection = "" } @@ -104,25 +119,32 @@ func (s *splitPaneLayout) View() string { if s.bottomPanel != nil && topSection != "" { bottomView := s.bottomPanel.View() - finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView) + finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView.String()) } else if s.bottomPanel != nil { - finalView = s.bottomPanel.View() + finalView = s.bottomPanel.View().String() } else { finalView = topSection } - if finalView != "" { - t := theme.CurrentTheme() + // TODO: think of a better way to handle multiple cursors + var cursor *tea.Cursor + if s.bottomPanel != nil { + cursor = s.bottomPanel.View().Cursor() + } else if s.rightPanel != nil { + cursor = s.rightPanel.View().Cursor() + } else if s.leftPanel != nil { + cursor = s.leftPanel.View().Cursor() + } - style := lipgloss.NewStyle(). - Width(s.width). - Height(s.height). - Background(t.Background()) + t := styles.CurrentTheme() - return style.Render(finalView) - } + style := t.S().Base. + Width(s.width). + Height(s.height) - return finalView + view := tea.NewView(style.Render(finalView)) + view.SetCursor(cursor) + return view } func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { @@ -130,9 +152,19 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { s.height = height var topHeight, bottomHeight int + var cmds []tea.Cmd if s.bottomPanel != nil { - topHeight = int(float64(height) * s.verticalRatio) - bottomHeight = height - topHeight + if s.fixedBottomHeight > 0 { + bottomHeight = s.fixedBottomHeight + topHeight = height - bottomHeight + } else { + topHeight = int(float64(height) * s.verticalRatio) + bottomHeight = height - topHeight + if bottomHeight <= 0 { + bottomHeight = 2 + topHeight = height - bottomHeight + } + } } else { topHeight = height bottomHeight = 0 @@ -140,8 +172,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { var leftWidth, rightWidth int if s.leftPanel != nil && s.rightPanel != nil { - leftWidth = int(float64(width) * s.ratio) - rightWidth = width - leftWidth + if s.fixedRightWidth > 0 { + rightWidth = s.fixedRightWidth + leftWidth = width - rightWidth + } else { + leftWidth = int(float64(width) * s.ratio) + rightWidth = width - leftWidth + if rightWidth <= 0 { + rightWidth = 2 + leftWidth = width - rightWidth + } + } } else if s.leftPanel != nil { leftWidth = width rightWidth = 0 @@ -150,20 +191,28 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd { rightWidth = width } - var cmds []tea.Cmd if s.leftPanel != nil { cmd := s.leftPanel.SetSize(leftWidth, topHeight) cmds = append(cmds, cmd) + if positionable, ok := s.leftPanel.(Positionable); ok { + cmds = append(cmds, positionable.SetPosition(0, 0)) + } } if s.rightPanel != nil { cmd := s.rightPanel.SetSize(rightWidth, topHeight) cmds = append(cmds, cmd) + if positionable, ok := s.rightPanel.(Positionable); ok { + cmds = append(cmds, positionable.SetPosition(leftWidth, 0)) + } } if s.bottomPanel != nil { cmd := s.bottomPanel.SetSize(width, bottomHeight) cmds = append(cmds, cmd) + if positionable, ok := s.bottomPanel.(Positionable); ok { + cmds = append(cmds, positionable.SetPosition(0, topHeight)) + } } return tea.Batch(cmds...) } @@ -240,11 +289,30 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding { return keys } -func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout { +func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd { + panels := map[LayoutPanel]Container{ + LeftPanel: s.leftPanel, + RightPanel: s.rightPanel, + BottomPanel: s.bottomPanel, + } + var cmds []tea.Cmd + for p, container := range panels { + if container == nil { + continue + } + if p == panel { + cmds = append(cmds, container.Focus()) + } else { + cmds = append(cmds, container.Blur()) + } + } + return tea.Batch(cmds...) +} +func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout { layout := &splitPaneLayout{ - ratio: 0.7, - verticalRatio: 0.9, // Default 90% for top section, 10% for bottom + ratio: 0.8, + verticalRatio: 0.92, // Default 90% for top section, 10% for bottom } for _, option := range options { option(layout) @@ -281,3 +349,15 @@ func WithVerticalRatio(ratio float64) SplitPaneOption { s.verticalRatio = ratio } } + +func WithFixedBottomHeight(height int) SplitPaneOption { + return func(s *splitPaneLayout) { + s.fixedBottomHeight = height + } +} + +func WithFixedRightWidth(width int) SplitPaneOption { + return func(s *splitPaneLayout) { + s.fixedRightWidth = width + } +} diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go deleted file mode 100644 index d297a34c2c1c828315b688c5fd4b60410cd97704..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat.go +++ /dev/null @@ -1,237 +0,0 @@ -package page - -import ( - "context" - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/completions" - "github.com/opencode-ai/opencode/internal/message" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/components/chat" - "github.com/opencode-ai/opencode/internal/tui/components/dialog" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -var ChatPage PageID = "chat" - -type chatPage struct { - app *app.App - editor layout.Container - messages layout.Container - layout layout.SplitPaneLayout - session session.Session - completionDialog dialog.CompletionDialog - showCompletionDialog bool -} - -type ChatKeyMap struct { - ShowCompletionDialog key.Binding - NewSession key.Binding - Cancel key.Binding -} - -var keyMap = ChatKeyMap{ - ShowCompletionDialog: key.NewBinding( - key.WithKeys("@"), - key.WithHelp("@", "Complete"), - ), - NewSession: key.NewBinding( - key.WithKeys("ctrl+n"), - key.WithHelp("ctrl+n", "new session"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), -} - -func (p *chatPage) Init() tea.Cmd { - cmds := []tea.Cmd{ - p.layout.Init(), - p.completionDialog.Init(), - } - return tea.Batch(cmds...) -} - -func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - cmd := p.layout.SetSize(msg.Width, msg.Height) - cmds = append(cmds, cmd) - case dialog.CompletionDialogCloseMsg: - p.showCompletionDialog = false - case chat.SendMsg: - cmd := p.sendMessage(msg.Text, msg.Attachments) - if cmd != nil { - return p, cmd - } - case dialog.CommandRunCustomMsg: - // Check if the agent is busy before executing custom commands - if p.app.CoderAgent.IsBusy() { - return p, util.ReportWarn("Agent is busy, please wait before executing a command...") - } - - // Process the command content with arguments if any - content := msg.Content - if msg.Args != nil { - // Replace all named arguments with their values - for name, value := range msg.Args { - placeholder := "$" + name - content = strings.ReplaceAll(content, placeholder, value) - } - } - - // Handle custom command execution - cmd := p.sendMessage(content, nil) - if cmd != nil { - return p, cmd - } - case chat.SessionSelectedMsg: - if p.session.ID == "" { - cmd := p.setSidebar() - if cmd != nil { - cmds = append(cmds, cmd) - } - } - p.session = msg - case tea.KeyMsg: - switch { - case key.Matches(msg, keyMap.ShowCompletionDialog): - p.showCompletionDialog = true - // Continue sending keys to layout->chat - case key.Matches(msg, keyMap.NewSession): - p.session = session.Session{} - return p, tea.Batch( - p.clearSidebar(), - util.CmdHandler(chat.SessionClearedMsg{}), - ) - case key.Matches(msg, keyMap.Cancel): - if p.session.ID != "" { - // Cancel the current session's generation process - // This allows users to interrupt long-running operations - p.app.CoderAgent.Cancel(p.session.ID) - return p, nil - } - } - } - if p.showCompletionDialog { - context, contextCmd := p.completionDialog.Update(msg) - p.completionDialog = context.(dialog.CompletionDialog) - cmds = append(cmds, contextCmd) - - // Doesn't forward event if enter key is pressed - if keyMsg, ok := msg.(tea.KeyMsg); ok { - if keyMsg.String() == "enter" { - return p, tea.Batch(cmds...) - } - } - } - - u, cmd := p.layout.Update(msg) - cmds = append(cmds, cmd) - p.layout = u.(layout.SplitPaneLayout) - - return p, tea.Batch(cmds...) -} - -func (p *chatPage) setSidebar() tea.Cmd { - sidebarContainer := layout.NewContainer( - chat.NewSidebarCmp(p.session, p.app.History), - layout.WithPadding(1, 1, 1, 1), - ) - return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init()) -} - -func (p *chatPage) clearSidebar() tea.Cmd { - return p.layout.ClearRightPanel() -} - -func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { - var cmds []tea.Cmd - if p.session.ID == "" { - session, err := p.app.Sessions.Create(context.Background(), "New Session") - if err != nil { - return util.ReportError(err) - } - - p.session = session - cmd := p.setSidebar() - if cmd != nil { - cmds = append(cmds, cmd) - } - cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) - } - - _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...) - if err != nil { - return util.ReportError(err) - } - return tea.Batch(cmds...) -} - -func (p *chatPage) SetSize(width, height int) tea.Cmd { - return p.layout.SetSize(width, height) -} - -func (p *chatPage) GetSize() (int, int) { - return p.layout.GetSize() -} - -func (p *chatPage) View() string { - layoutView := p.layout.View() - - if p.showCompletionDialog { - _, layoutHeight := p.layout.GetSize() - editorWidth, editorHeight := p.editor.GetSize() - - p.completionDialog.SetWidth(editorWidth) - overlay := p.completionDialog.View() - - layoutView = layout.PlaceOverlay( - 0, - layoutHeight-editorHeight-lipgloss.Height(overlay), - overlay, - layoutView, - false, - ) - } - - return layoutView -} - -func (p *chatPage) BindingKeys() []key.Binding { - bindings := layout.KeyMapToSlice(keyMap) - bindings = append(bindings, p.messages.BindingKeys()...) - bindings = append(bindings, p.editor.BindingKeys()...) - return bindings -} - -func NewChatPage(app *app.App) tea.Model { - cg := completions.NewFileAndFolderContextGroup() - completionDialog := dialog.NewCompletionDialogCmp(cg) - - messagesContainer := layout.NewContainer( - chat.NewMessagesCmp(app), - layout.WithPadding(1, 1, 0, 1), - ) - editorContainer := layout.NewContainer( - chat.NewEditorCmp(app), - layout.WithBorder(true, false, false, false), - ) - return &chatPage{ - app: app, - editor: editorContainer, - messages: messagesContainer, - completionDialog: completionDialog, - layout: layout.NewSplitPane( - layout.WithLeftPanel(messagesContainer), - layout.WithBottomPanel(editorContainer), - ), - } -} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go new file mode 100644 index 0000000000000000000000000000000000000000..6ca1ac1e91d42b0896cfa3f8dc0b723ca53b4063 --- /dev/null +++ b/internal/tui/page/chat/chat.go @@ -0,0 +1,194 @@ +package chat + +import ( + "context" + + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/models" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/chat/editor" + "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/page" + "github.com/charmbracelet/crush/internal/tui/util" +) + +var ChatPage page.PageID = "chat" + +type ChatFocusedMsg struct { + Focused bool // True if the chat input is focused, false otherwise +} + +type ( + OpenFilePickerMsg struct{} + chatPage struct { + app *app.App + + layout layout.SplitPaneLayout + + session session.Session + + keyMap KeyMap + + chatFocused bool + } +) + +func (p *chatPage) Init() tea.Cmd { + cmd := p.layout.Init() + return tea.Batch( + cmd, + p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor) + ) +} + +func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := p.layout.SetSize(msg.Width, msg.Height) + cmds = append(cmds, cmd) + case chat.SendMsg: + cmd := p.sendMessage(msg.Text, msg.Attachments) + if cmd != nil { + return p, cmd + } + case commands.CommandRunCustomMsg: + // Check if the agent is busy before executing custom commands + if p.app.CoderAgent.IsBusy() { + return p, util.ReportWarn("Agent is busy, please wait before executing a command...") + } + + // Handle custom command execution + cmd := p.sendMessage(msg.Content, nil) + if cmd != nil { + return p, cmd + } + case chat.SessionSelectedMsg: + if p.session.ID == "" { + cmd := p.setMessages() + if cmd != nil { + cmds = append(cmds, cmd) + } + } + p.session = msg + case tea.KeyPressMsg: + switch { + case key.Matches(msg, p.keyMap.NewSession): + p.session = session.Session{} + return p, tea.Batch( + p.clearMessages(), + util.CmdHandler(chat.SessionClearedMsg{}), + ) + + case key.Matches(msg, p.keyMap.FilePicker): + cfg := config.Get() + agentCfg := cfg.Agents[config.AgentCoder] + selectedModelID := agentCfg.Model + model := models.SupportedModels[selectedModelID] + if model.SupportsAttachments { + return p, util.CmdHandler(OpenFilePickerMsg{}) + } else { + return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID)) + } + case key.Matches(msg, p.keyMap.Tab): + if p.session.ID == "" { + return p, nil + } + p.chatFocused = !p.chatFocused + if p.chatFocused { + cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel)) + cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true})) + } else { + cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel)) + cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false})) + } + return p, tea.Batch(cmds...) + case key.Matches(msg, p.keyMap.Cancel): + if p.session.ID != "" { + // Cancel the current session's generation process + // This allows users to interrupt long-running operations + p.app.CoderAgent.Cancel(p.session.ID) + return p, nil + } + } + } + u, cmd := p.layout.Update(msg) + cmds = append(cmds, cmd) + p.layout = u.(layout.SplitPaneLayout) + + return p, tea.Batch(cmds...) +} + +func (p *chatPage) setMessages() tea.Cmd { + messagesContainer := layout.NewContainer( + chat.NewMessagesListCmp(p.app), + layout.WithPadding(1, 1, 0, 1), + ) + return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init()) +} + +func (p *chatPage) clearMessages() tea.Cmd { + return p.layout.ClearLeftPanel() +} + +func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { + var cmds []tea.Cmd + if p.session.ID == "" { + session, err := p.app.Sessions.Create(context.Background(), "New Session") + if err != nil { + return util.ReportError(err) + } + + p.session = session + cmd := p.setMessages() + if cmd != nil { + cmds = append(cmds, cmd) + } + cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) + } + + _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...) + if err != nil { + return util.ReportError(err) + } + return tea.Batch(cmds...) +} + +func (p *chatPage) SetSize(width, height int) tea.Cmd { + return p.layout.SetSize(width, height) +} + +func (p *chatPage) GetSize() (int, int) { + return p.layout.GetSize() +} + +func (p *chatPage) View() tea.View { + return p.layout.View() +} + +func NewChatPage(app *app.App) util.Model { + sidebarContainer := layout.NewContainer( + sidebar.NewSidebarCmp(), + layout.WithPadding(1, 1, 1, 1), + ) + editorContainer := layout.NewContainer( + editor.NewEditorCmp(app), + ) + return &chatPage{ + app: app, + layout: layout.NewSplitPane( + layout.WithRightPanel(sidebarContainer), + layout.WithBottomPanel(editorContainer), + layout.WithFixedBottomHeight(5), + layout.WithFixedRightWidth(31), + ), + keyMap: DefaultKeyMap(), + } +} diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..8d11d4cae5297e8e6b765e841bf1e035940b707a --- /dev/null +++ b/internal/tui/page/chat/keys.go @@ -0,0 +1,52 @@ +package chat + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + NewSession key.Binding + FilePicker key.Binding + Cancel key.Binding + Tab key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + NewSession: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new session"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "change focus"), + ), + FilePicker: key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "select files to upload"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Tab, + } +} diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go deleted file mode 100644 index 9bd545287f4c35c1ea99d33efd5f274275d63a4f..0000000000000000000000000000000000000000 --- a/internal/tui/page/logs.go +++ /dev/null @@ -1,83 +0,0 @@ -package page - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/components/logs" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" -) - -var LogsPage PageID = "logs" - -type LogPage interface { - tea.Model - layout.Sizeable - layout.Bindings -} -type logsPage struct { - width, height int - table layout.Container - details layout.Container -} - -func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.width = msg.Width - p.height = msg.Height - return p, p.SetSize(msg.Width, msg.Height) - } - - table, cmd := p.table.Update(msg) - cmds = append(cmds, cmd) - p.table = table.(layout.Container) - details, cmd := p.details.Update(msg) - cmds = append(cmds, cmd) - p.details = details.(layout.Container) - - return p, tea.Batch(cmds...) -} - -func (p *logsPage) View() string { - style := styles.BaseStyle().Width(p.width).Height(p.height) - return style.Render(lipgloss.JoinVertical(lipgloss.Top, - p.table.View(), - p.details.View(), - )) -} - -func (p *logsPage) BindingKeys() []key.Binding { - return p.table.BindingKeys() -} - -// GetSize implements LogPage. -func (p *logsPage) GetSize() (int, int) { - return p.width, p.height -} - -// SetSize implements LogPage. -func (p *logsPage) SetSize(width int, height int) tea.Cmd { - p.width = width - p.height = height - return tea.Batch( - p.table.SetSize(width, height/2), - p.details.SetSize(width, height/2), - ) -} - -func (p *logsPage) Init() tea.Cmd { - return tea.Batch( - p.table.Init(), - p.details.Init(), - ) -} - -func NewLogsPage() LogPage { - return &logsPage{ - table: layout.NewContainer(logs.NewLogsTable(), layout.WithBorderAll()), - details: layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderAll()), - } -} diff --git a/internal/tui/page/logs/keys.go b/internal/tui/page/logs/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..e80b3183644142cc6044fc7f45698ee5b01fccb2 --- /dev/null +++ b/internal/tui/page/logs/keys.go @@ -0,0 +1,37 @@ +package logs + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/crush/internal/tui/layout" +) + +type KeyMap struct { + Back key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Back: key.NewBinding( + key.WithKeys("esc", "backspace"), + key.WithHelp("esc/backspace", "back to chat"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Back, + } +} diff --git a/internal/tui/page/logs/logs.go b/internal/tui/page/logs/logs.go new file mode 100644 index 0000000000000000000000000000000000000000..5b86fb325beed3d33e866ec6d268610d3f58016c --- /dev/null +++ b/internal/tui/page/logs/logs.go @@ -0,0 +1,100 @@ +package logs + +import ( + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core" + logsComponents "github.com/charmbracelet/crush/internal/tui/components/logs" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/page" + "github.com/charmbracelet/crush/internal/tui/page/chat" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" +) + +var LogsPage page.PageID = "logs" + +type LogPage interface { + util.Model + layout.Sizeable +} + +type logsPage struct { + width, height int + table logsComponents.TableComponent + details logsComponents.DetailComponent + keyMap KeyMap +} + +func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.width = msg.Width + p.height = msg.Height + return p, p.SetSize(msg.Width, msg.Height) + case tea.KeyMsg: + switch { + case key.Matches(msg, p.keyMap.Back): + return p, util.CmdHandler(page.PageChangeMsg{ID: chat.ChatPage}) + } + } + + table, cmd := p.table.Update(msg) + cmds = append(cmds, cmd) + p.table = table.(logsComponents.TableComponent) + details, cmd := p.details.Update(msg) + cmds = append(cmds, cmd) + p.details = details.(logsComponents.DetailComponent) + + return p, tea.Batch(cmds...) +} + +func (p *logsPage) View() tea.View { + baseStyle := styles.CurrentTheme().S().Base + style := baseStyle.Width(p.width).Height(p.height).Padding(1) + title := core.Title("Logs", p.width-2) + + return tea.NewView( + style.Render( + lipgloss.JoinVertical(lipgloss.Top, + title, + p.details.View().String(), + p.table.View().String(), + ), + ), + ) +} + +// GetSize implements LogPage. +func (p *logsPage) GetSize() (int, int) { + return p.width, p.height +} + +// SetSize implements LogPage. +func (p *logsPage) SetSize(width int, height int) tea.Cmd { + p.width = width + p.height = height + availableHeight := height - 2 // Padding for top and bottom + availableHeight -= 1 // title height + return tea.Batch( + p.table.SetSize(width-2, availableHeight/2), + p.details.SetSize(width-2, availableHeight/2), + ) +} + +func (p *logsPage) Init() tea.Cmd { + return tea.Batch( + p.table.Init(), + p.details.Init(), + ) +} + +func NewLogsPage() LogPage { + return &logsPage{ + details: logsComponents.NewLogsDetails(), + table: logsComponents.NewLogsTable(), + keyMap: DefaultKeyMap(), + } +} diff --git a/internal/tui/styles/background.go b/internal/tui/styles/background.go deleted file mode 100644 index 2fbb34efbbe52ecd5e233c33ee32fbb2981fb8f1..0000000000000000000000000000000000000000 --- a/internal/tui/styles/background.go +++ /dev/null @@ -1,123 +0,0 @@ -package styles - -import ( - "fmt" - "regexp" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") - -func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) { - r, g, b, a := c.RGBA() - - // Un-premultiply alpha if needed - if a > 0 && a < 0xffff { - r = (r * 0xffff) / a - g = (g * 0xffff) / a - b = (b * 0xffff) / a - } - - // Convert from 16-bit to 8-bit color - return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8) -} - -// ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes -// in `input` with a single 24‑bit background (48;2;R;G;B). -func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string { - // Precompute our new-bg sequence once - r, g, b := getColorRGB(newBgColor) - newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b) - - return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string { - const ( - escPrefixLen = 2 // "\x1b[" - escSuffixLen = 1 // "m" - ) - - raw := seq - start := escPrefixLen - end := len(raw) - escSuffixLen - - var sb strings.Builder - // reserve enough space: original content minus bg codes + our newBg - sb.Grow((end - start) + len(newBg) + 2) - - // scan from start..end, token by token - for i := start; i < end; { - // find the next ';' or end - j := i - for j < end && raw[j] != ';' { - j++ - } - token := raw[i:j] - - // fast‑path: skip "48;5;N" or "48;2;R;G;B" - if len(token) == 2 && token[0] == '4' && token[1] == '8' { - k := j + 1 - if k < end { - // find next token - l := k - for l < end && raw[l] != ';' { - l++ - } - next := raw[k:l] - if next == "5" { - // skip "48;5;N" - m := l + 1 - for m < end && raw[m] != ';' { - m++ - } - i = m + 1 - continue - } else if next == "2" { - // skip "48;2;R;G;B" - m := l + 1 - for count := 0; count < 3 && m < end; count++ { - for m < end && raw[m] != ';' { - m++ - } - m++ - } - i = m - continue - } - } - } - - // decide whether to keep this token - // manually parse ASCII digits to int - isNum := true - val := 0 - for p := i; p < j; p++ { - c := raw[p] - if c < '0' || c > '9' { - isNum = false - break - } - val = val*10 + int(c-'0') - } - keep := !isNum || - ((val < 40 || val > 47) && (val < 100 || val > 107) && val != 49) - - if keep { - if sb.Len() > 0 { - sb.WriteByte(';') - } - sb.WriteString(token) - } - // advance past this token (and the semicolon) - i = j + 1 - } - - // append our new background - if sb.Len() > 0 { - sb.WriteByte(';') - } - sb.WriteString(newBg) - - return "\x1b[" + sb.String() + "m" - }) -} diff --git a/internal/tui/styles/chroma.go b/internal/tui/styles/chroma.go new file mode 100644 index 0000000000000000000000000000000000000000..b6521bea45ea4972cc25711116ff69f2588dd68f --- /dev/null +++ b/internal/tui/styles/chroma.go @@ -0,0 +1,79 @@ +package styles + +import ( + "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/glamour/v2/ansi" +) + +func chromaStyle(style ansi.StylePrimitive) string { + var s string + + if style.Color != nil { + s = *style.Color + } + if style.BackgroundColor != nil { + if s != "" { + s += " " + } + s += "bg:" + *style.BackgroundColor + } + if style.Italic != nil && *style.Italic { + if s != "" { + s += " " + } + s += "italic" + } + if style.Bold != nil && *style.Bold { + if s != "" { + s += " " + } + s += "bold" + } + if style.Underline != nil && *style.Underline { + if s != "" { + s += " " + } + s += "underline" + } + + return s +} + +func GetChromaTheme() chroma.StyleEntries { + t := CurrentTheme() + rules := t.S().Markdown.CodeBlock + + return chroma.StyleEntries{ + chroma.Text: chromaStyle(rules.Chroma.Text), + chroma.Error: chromaStyle(rules.Chroma.Error), + chroma.Comment: chromaStyle(rules.Chroma.Comment), + chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc), + chroma.Keyword: chromaStyle(rules.Chroma.Keyword), + chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved), + chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace), + chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType), + chroma.Operator: chromaStyle(rules.Chroma.Operator), + chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation), + chroma.Name: chromaStyle(rules.Chroma.Name), + chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin), + chroma.NameTag: chromaStyle(rules.Chroma.NameTag), + chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute), + chroma.NameClass: chromaStyle(rules.Chroma.NameClass), + chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant), + chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator), + chroma.NameException: chromaStyle(rules.Chroma.NameException), + chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction), + chroma.NameOther: chromaStyle(rules.Chroma.NameOther), + chroma.Literal: chromaStyle(rules.Chroma.Literal), + chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber), + chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate), + chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString), + chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape), + chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted), + chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph), + chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted), + chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong), + chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading), + chroma.Background: chromaStyle(rules.Chroma.Background), + } +} diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go new file mode 100644 index 0000000000000000000000000000000000000000..b35008c2a65e9c8b19ec456515d0a72823185c0b --- /dev/null +++ b/internal/tui/styles/crush.go @@ -0,0 +1,51 @@ +package styles + +import ( + "github.com/charmbracelet/x/exp/charmtone" +) + +func NewCrushTheme() *Theme { + return &Theme{ + Name: "crush", + IsDark: true, + + Primary: charmtone.Charple, + Secondary: charmtone.Dolly, + Tertiary: charmtone.Bok, + Accent: charmtone.Zest, + // Backgrounds + BgBase: charmtone.Pepper, + BgSubtle: charmtone.Charcoal, + BgOverlay: charmtone.Iron, + + // Foregrounds + FgBase: charmtone.Ash, + FgMuted: charmtone.Squid, + FgHalfMuted: charmtone.Smoke, + FgSubtle: charmtone.Oyster, + FgSelected: charmtone.Salt, + + // Borders + Border: charmtone.Charcoal, + BorderFocus: charmtone.Charple, + + // Status + Success: charmtone.Guac, + Error: charmtone.Sriracha, + Warning: charmtone.Uni, + Info: charmtone.Malibu, + + // Colors + White: charmtone.Butter, + + Blue: charmtone.Malibu, + + Green: charmtone.Julep, + GreenDark: charmtone.Guac, + GreenLight: charmtone.Bok, + + Red: charmtone.Coral, + RedDark: charmtone.Sriracha, + RedLight: charmtone.Salmon, + } +} diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index 87255ccd2801f662a2282b3dad237c36464f0781..2b02442437918adbc675bd3ff01b5e5cd71902b7 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -1,14 +1,17 @@ package styles const ( - OpenCodeIcon string = "⌬" - CheckIcon string = "✓" - ErrorIcon string = "✖" + ErrorIcon string = "×" WarningIcon string = "⚠" InfoIcon string = "" HintIcon string = "i" SpinnerIcon string = "..." LoadingIcon string = "⟳" DocumentIcon string = "🖼" + + // Tool call icons + ToolPending string = "●" + ToolSuccess string = "✓" + ToolError string = "×" ) diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go index 6b43d97cfeeea6829d298816b6f90f9d61e91629..deda517add19a306d41320fdaab0c8895f63919e 100644 --- a/internal/tui/styles/markdown.go +++ b/internal/tui/styles/markdown.go @@ -1,14 +1,9 @@ package styles import ( - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/theme" + "github.com/charmbracelet/glamour/v2" ) -const defaultMargin = 1 - // Helper functions for style pointers func boolPtr(b bool) *bool { return &b } func stringPtr(s string) *string { return &s } @@ -16,269 +11,10 @@ func uintPtr(u uint) *uint { return &u } // returns a glamour TermRenderer configured with the current theme func GetMarkdownRenderer(width int) *glamour.TermRenderer { + t := CurrentTheme() r, _ := glamour.NewTermRenderer( - glamour.WithStyles(generateMarkdownStyleConfig()), + glamour.WithStyles(t.S().Markdown), glamour.WithWordWrap(width), ) return r } - -// creates an ansi.StyleConfig for markdown rendering -// using adaptive colors from the provided theme. -func generateMarkdownStyleConfig() ansi.StyleConfig { - t := theme.CurrentTheme() - - return ansi.StyleConfig{ - Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockPrefix: "", - BlockSuffix: "", - Color: stringPtr(adaptiveColorToString(t.MarkdownText())), - }, - Margin: uintPtr(defaultMargin), - }, - BlockQuote: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownBlockQuote())), - Italic: boolPtr(true), - Prefix: "┃ ", - }, - Indent: uintPtr(1), - IndentToken: stringPtr(BaseStyle().Render(" ")), - }, - List: ansi.StyleList{ - LevelIndent: defaultMargin, - StyleBlock: ansi.StyleBlock{ - IndentToken: stringPtr(BaseStyle().Render(" ")), - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownText())), - }, - }, - }, - Heading: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockSuffix: "\n", - Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())), - Bold: boolPtr(true), - }, - }, - H1: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "# ", - Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())), - Bold: boolPtr(true), - }, - }, - H2: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "## ", - Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())), - Bold: boolPtr(true), - }, - }, - H3: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "### ", - Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())), - Bold: boolPtr(true), - }, - }, - H4: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "#### ", - Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())), - Bold: boolPtr(true), - }, - }, - H5: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "##### ", - Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())), - Bold: boolPtr(true), - }, - }, - H6: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: "###### ", - Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())), - Bold: boolPtr(true), - }, - }, - Strikethrough: ansi.StylePrimitive{ - CrossedOut: boolPtr(true), - Color: stringPtr(adaptiveColorToString(t.TextMuted())), - }, - Emph: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownEmph())), - Italic: boolPtr(true), - }, - Strong: ansi.StylePrimitive{ - Bold: boolPtr(true), - Color: stringPtr(adaptiveColorToString(t.MarkdownStrong())), - }, - HorizontalRule: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownHorizontalRule())), - Format: "\n─────────────────────────────────────────\n", - }, - Item: ansi.StylePrimitive{ - BlockPrefix: "• ", - Color: stringPtr(adaptiveColorToString(t.MarkdownListItem())), - }, - Enumeration: ansi.StylePrimitive{ - BlockPrefix: ". ", - Color: stringPtr(adaptiveColorToString(t.MarkdownListEnumeration())), - }, - Task: ansi.StyleTask{ - StylePrimitive: ansi.StylePrimitive{}, - Ticked: "[✓] ", - Unticked: "[ ] ", - }, - Link: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownLink())), - Underline: boolPtr(true), - }, - LinkText: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownLinkText())), - Bold: boolPtr(true), - }, - Image: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownImage())), - Underline: boolPtr(true), - Format: "🖼 {{.text}}", - }, - ImageText: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownImageText())), - Format: "{{.text}}", - }, - Code: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownCode())), - Prefix: "", - Suffix: "", - }, - }, - CodeBlock: ansi.StyleCodeBlock{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", - Color: stringPtr(adaptiveColorToString(t.MarkdownCodeBlock())), - }, - Margin: uintPtr(defaultMargin), - }, - Chroma: &ansi.Chroma{ - Text: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownText())), - }, - Error: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.Error())), - }, - Comment: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxComment())), - }, - CommentPreproc: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())), - }, - Keyword: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())), - }, - KeywordReserved: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())), - }, - KeywordNamespace: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())), - }, - KeywordType: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxType())), - }, - Operator: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxOperator())), - }, - Punctuation: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxPunctuation())), - }, - Name: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxVariable())), - }, - NameBuiltin: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxVariable())), - }, - NameTag: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())), - }, - NameAttribute: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxFunction())), - }, - NameClass: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxType())), - }, - NameConstant: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxVariable())), - }, - NameDecorator: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxFunction())), - }, - NameFunction: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxFunction())), - }, - LiteralNumber: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxNumber())), - }, - LiteralString: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxString())), - }, - LiteralStringEscape: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())), - }, - GenericDeleted: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.DiffRemoved())), - }, - GenericEmph: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownEmph())), - Italic: boolPtr(true), - }, - GenericInserted: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.DiffAdded())), - }, - GenericStrong: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownStrong())), - Bold: boolPtr(true), - }, - GenericSubheading: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())), - }, - }, - }, - Table: ansi.StyleTable{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - BlockPrefix: "\n", - BlockSuffix: "\n", - }, - }, - CenterSeparator: stringPtr("┼"), - ColumnSeparator: stringPtr("│"), - RowSeparator: stringPtr("─"), - }, - DefinitionDescription: ansi.StylePrimitive{ - BlockPrefix: "\n ❯ ", - Color: stringPtr(adaptiveColorToString(t.MarkdownLinkText())), - }, - Text: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownText())), - }, - Paragraph: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(adaptiveColorToString(t.MarkdownText())), - }, - }, - } -} - -// adaptiveColorToString converts a lipgloss.AdaptiveColor to the appropriate -// hex color string based on the current terminal background -func adaptiveColorToString(color lipgloss.AdaptiveColor) string { - if lipgloss.HasDarkBackground() { - return color.Dark - } - return color.Light -} diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go deleted file mode 100644 index 7094b537318843db3b453b194c3617fc219e1c89..0000000000000000000000000000000000000000 --- a/internal/tui/styles/styles.go +++ /dev/null @@ -1,155 +0,0 @@ -package styles - -import ( - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/tui/theme" -) - -var ( - ImageBakcground = "#212121" -) - -// Style generation functions that use the current theme - -// BaseStyle returns the base style with background and foreground colors -func BaseStyle() lipgloss.Style { - t := theme.CurrentTheme() - return lipgloss.NewStyle(). - Background(t.Background()). - Foreground(t.Text()) -} - -// Regular returns a basic unstyled lipgloss.Style -func Regular() lipgloss.Style { - return lipgloss.NewStyle() -} - -// Bold returns a bold style -func Bold() lipgloss.Style { - return Regular().Bold(true) -} - -// Padded returns a style with horizontal padding -func Padded() lipgloss.Style { - return Regular().Padding(0, 1) -} - -// Border returns a style with a normal border -func Border() lipgloss.Style { - t := theme.CurrentTheme() - return Regular(). - Border(lipgloss.NormalBorder()). - BorderForeground(t.BorderNormal()) -} - -// ThickBorder returns a style with a thick border -func ThickBorder() lipgloss.Style { - t := theme.CurrentTheme() - return Regular(). - Border(lipgloss.ThickBorder()). - BorderForeground(t.BorderNormal()) -} - -// DoubleBorder returns a style with a double border -func DoubleBorder() lipgloss.Style { - t := theme.CurrentTheme() - return Regular(). - Border(lipgloss.DoubleBorder()). - BorderForeground(t.BorderNormal()) -} - -// FocusedBorder returns a style with a border using the focused border color -func FocusedBorder() lipgloss.Style { - t := theme.CurrentTheme() - return Regular(). - Border(lipgloss.NormalBorder()). - BorderForeground(t.BorderFocused()) -} - -// DimBorder returns a style with a border using the dim border color -func DimBorder() lipgloss.Style { - t := theme.CurrentTheme() - return Regular(). - Border(lipgloss.NormalBorder()). - BorderForeground(t.BorderDim()) -} - -// PrimaryColor returns the primary color from the current theme -func PrimaryColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().Primary() -} - -// SecondaryColor returns the secondary color from the current theme -func SecondaryColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().Secondary() -} - -// AccentColor returns the accent color from the current theme -func AccentColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().Accent() -} - -// ErrorColor returns the error color from the current theme -func ErrorColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().Error() -} - -// WarningColor returns the warning color from the current theme -func WarningColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().Warning() -} - -// SuccessColor returns the success color from the current theme -func SuccessColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().Success() -} - -// InfoColor returns the info color from the current theme -func InfoColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().Info() -} - -// TextColor returns the text color from the current theme -func TextColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().Text() -} - -// TextMutedColor returns the muted text color from the current theme -func TextMutedColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().TextMuted() -} - -// TextEmphasizedColor returns the emphasized text color from the current theme -func TextEmphasizedColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().TextEmphasized() -} - -// BackgroundColor returns the background color from the current theme -func BackgroundColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().Background() -} - -// BackgroundSecondaryColor returns the secondary background color from the current theme -func BackgroundSecondaryColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().BackgroundSecondary() -} - -// BackgroundDarkerColor returns the darker background color from the current theme -func BackgroundDarkerColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().BackgroundDarker() -} - -// BorderNormalColor returns the normal border color from the current theme -func BorderNormalColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().BorderNormal() -} - -// BorderFocusedColor returns the focused border color from the current theme -func BorderFocusedColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().BorderFocused() -} - -// BorderDimColor returns the dim border color from the current theme -func BorderDimColor() lipgloss.AdaptiveColor { - return theme.CurrentTheme().BorderDim() -} diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go new file mode 100644 index 0000000000000000000000000000000000000000..bb3a11aa554062964d81bf37bca00c6f1220d8ca --- /dev/null +++ b/internal/tui/styles/theme.go @@ -0,0 +1,630 @@ +package styles + +import ( + "fmt" + "image/color" + "strings" + + "github.com/charmbracelet/bubbles/v2/filepicker" + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/textarea" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/glamour/v2/ansi" + "github.com/charmbracelet/lipgloss/v2" + "github.com/lucasb-eyer/go-colorful" + "github.com/rivo/uniseg" +) + +const ( + defaultListIndent = 2 + defaultListLevelIndent = 4 + defaultMargin = 2 +) + +type Theme struct { + Name string + IsDark bool + + Primary color.Color + Secondary color.Color + Tertiary color.Color + Accent color.Color + + BgBase color.Color + BgSubtle color.Color + BgOverlay color.Color + + FgBase color.Color + FgMuted color.Color + FgHalfMuted color.Color + FgSubtle color.Color + FgSelected color.Color + + Border color.Color + BorderFocus color.Color + + Success color.Color + Error color.Color + Warning color.Color + Info color.Color + + // Colors + // White + White color.Color + // Blues + Blue color.Color + + // Greens + Green color.Color + GreenDark color.Color + GreenLight color.Color + + // Reds + Red color.Color + RedDark color.Color + RedLight color.Color + + // TODO: add any others needed + + styles *Styles +} + +type Diff struct { + Added color.Color + Removed color.Color + Context color.Color + HunkHeader color.Color + HighlightAdded color.Color + HighlightRemoved color.Color + AddedBg color.Color + RemovedBg color.Color + ContextBg color.Color + LineNumber color.Color + AddedLineNumberBg color.Color + RemovedLineNumberBg color.Color +} + +type Styles struct { + Base lipgloss.Style + SelectedBase lipgloss.Style + + Title lipgloss.Style + Subtitle lipgloss.Style + Text lipgloss.Style + TextSelected lipgloss.Style + Muted lipgloss.Style + Subtle lipgloss.Style + + Success lipgloss.Style + Error lipgloss.Style + Warning lipgloss.Style + Info lipgloss.Style + + // Markdown & Chroma + Markdown ansi.StyleConfig + + // Inputs + TextInput textinput.Styles + TextArea textarea.Styles + + // Help + Help help.Styles + + // Diff + Diff Diff + + // FilePicker + FilePicker filepicker.Styles +} + +func (t *Theme) S() *Styles { + if t.styles == nil { + t.styles = t.buildStyles() + } + return t.styles +} + +func (t *Theme) buildStyles() *Styles { + base := lipgloss.NewStyle(). + Foreground(t.FgBase) + return &Styles{ + Base: base, + + SelectedBase: base.Background(t.Primary), + + Title: base. + Foreground(t.Accent). + Bold(true), + + Subtitle: base. + Foreground(t.Secondary). + Bold(true), + + Text: base, + TextSelected: base.Background(t.Primary).Foreground(t.FgSelected), + + Muted: base.Foreground(t.FgMuted), + + Subtle: base.Foreground(t.FgSubtle), + + Success: base.Foreground(t.Success), + + Error: base.Foreground(t.Error), + + Warning: base.Foreground(t.Warning), + + Info: base.Foreground(t.Info), + + TextInput: textinput.Styles{ + Focused: textinput.StyleState{ + Text: base, + Placeholder: base.Foreground(t.FgMuted), + Prompt: base.Foreground(t.Tertiary), + Suggestion: base.Foreground(t.FgMuted), + }, + Blurred: textinput.StyleState{ + Text: base.Foreground(t.FgMuted), + Placeholder: base.Foreground(t.FgMuted), + Prompt: base.Foreground(t.FgMuted), + Suggestion: base.Foreground(t.FgMuted), + }, + Cursor: textinput.CursorStyle{ + Color: t.Secondary, + Shape: tea.CursorBar, + Blink: true, + }, + }, + TextArea: textarea.Styles{ + Focused: textarea.StyleState{ + Base: base, + Text: base, + LineNumber: base.Foreground(t.FgSubtle), + CursorLine: base, + CursorLineNumber: base.Foreground(t.FgSubtle), + Placeholder: base.Foreground(t.FgMuted), + Prompt: base.Foreground(t.Tertiary), + }, + Blurred: textarea.StyleState{ + Base: base, + Text: base.Foreground(t.FgMuted), + LineNumber: base.Foreground(t.FgMuted), + CursorLine: base, + CursorLineNumber: base.Foreground(t.FgMuted), + Placeholder: base.Foreground(t.FgMuted), + Prompt: base.Foreground(t.FgMuted), + }, + Cursor: textarea.CursorStyle{ + Color: t.Secondary, + Shape: tea.CursorBar, + Blink: true, + }, + }, + + // TODO: update using the colors and add colors if missing + Markdown: ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + // BlockPrefix: "\n", + // BlockSuffix: "\n", + Color: stringPtr("252"), + }, + // Margin: uintPtr(defaultMargin), + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + Indent: uintPtr(1), + IndentToken: stringPtr("│ "), + }, + List: ansi.StyleList{ + LevelIndent: defaultListIndent, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockSuffix: "\n", + Color: stringPtr("39"), + Bold: boolPtr(true), + }, + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: stringPtr("228"), + BackgroundColor: stringPtr("63"), + Bold: boolPtr(true), + }, + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "## ", + }, + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "### ", + }, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "#### ", + }, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "##### ", + }, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "###### ", + Color: stringPtr("35"), + Bold: boolPtr(false), + }, + }, + Strikethrough: ansi.StylePrimitive{ + CrossedOut: boolPtr(true), + }, + Emph: ansi.StylePrimitive{ + Italic: boolPtr(true), + }, + Strong: ansi.StylePrimitive{ + Bold: boolPtr(true), + }, + HorizontalRule: ansi.StylePrimitive{ + Color: stringPtr("240"), + Format: "\n--------\n", + }, + Item: ansi.StylePrimitive{ + BlockPrefix: "• ", + }, + Enumeration: ansi.StylePrimitive{ + BlockPrefix: ". ", + }, + Task: ansi.StyleTask{ + StylePrimitive: ansi.StylePrimitive{}, + Ticked: "[✓] ", + Unticked: "[ ] ", + }, + Link: ansi.StylePrimitive{ + Color: stringPtr("30"), + Underline: boolPtr(true), + }, + LinkText: ansi.StylePrimitive{ + Color: stringPtr("35"), + Bold: boolPtr(true), + }, + Image: ansi.StylePrimitive{ + Color: stringPtr("212"), + Underline: boolPtr(true), + }, + ImageText: ansi.StylePrimitive{ + Color: stringPtr("243"), + Format: "Image: {{.text}} →", + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: stringPtr("203"), + BackgroundColor: stringPtr("236"), + }, + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: stringPtr("244"), + }, + Margin: uintPtr(defaultMargin), + }, + Chroma: &ansi.Chroma{ + Text: ansi.StylePrimitive{ + Color: stringPtr("#C4C4C4"), + }, + Error: ansi.StylePrimitive{ + Color: stringPtr("#F1F1F1"), + BackgroundColor: stringPtr("#F05B5B"), + }, + Comment: ansi.StylePrimitive{ + Color: stringPtr("#676767"), + }, + CommentPreproc: ansi.StylePrimitive{ + Color: stringPtr("#FF875F"), + }, + Keyword: ansi.StylePrimitive{ + Color: stringPtr("#00AAFF"), + }, + KeywordReserved: ansi.StylePrimitive{ + Color: stringPtr("#FF5FD2"), + }, + KeywordNamespace: ansi.StylePrimitive{ + Color: stringPtr("#FF5F87"), + }, + KeywordType: ansi.StylePrimitive{ + Color: stringPtr("#6E6ED8"), + }, + Operator: ansi.StylePrimitive{ + Color: stringPtr("#EF8080"), + }, + Punctuation: ansi.StylePrimitive{ + Color: stringPtr("#E8E8A8"), + }, + Name: ansi.StylePrimitive{ + Color: stringPtr("#C4C4C4"), + }, + NameBuiltin: ansi.StylePrimitive{ + Color: stringPtr("#FF8EC7"), + }, + NameTag: ansi.StylePrimitive{ + Color: stringPtr("#B083EA"), + }, + NameAttribute: ansi.StylePrimitive{ + Color: stringPtr("#7A7AE6"), + }, + NameClass: ansi.StylePrimitive{ + Color: stringPtr("#F1F1F1"), + Underline: boolPtr(true), + Bold: boolPtr(true), + }, + NameDecorator: ansi.StylePrimitive{ + Color: stringPtr("#FFFF87"), + }, + NameFunction: ansi.StylePrimitive{ + Color: stringPtr("#00D787"), + }, + LiteralNumber: ansi.StylePrimitive{ + Color: stringPtr("#6EEFC0"), + }, + LiteralString: ansi.StylePrimitive{ + Color: stringPtr("#C69669"), + }, + LiteralStringEscape: ansi.StylePrimitive{ + Color: stringPtr("#AFFFD7"), + }, + GenericDeleted: ansi.StylePrimitive{ + Color: stringPtr("#FD5B5B"), + }, + GenericEmph: ansi.StylePrimitive{ + Italic: boolPtr(true), + }, + GenericInserted: ansi.StylePrimitive{ + Color: stringPtr("#00D787"), + }, + GenericStrong: ansi.StylePrimitive{ + Bold: boolPtr(true), + }, + GenericSubheading: ansi.StylePrimitive{ + Color: stringPtr("#777777"), + }, + Background: ansi.StylePrimitive{ + BackgroundColor: stringPtr("#373737"), + }, + }, + }, + Table: ansi.StyleTable{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + }, + DefinitionDescription: ansi.StylePrimitive{ + BlockPrefix: "\n ", + }, + }, + + Help: help.Styles{ + ShortKey: base.Foreground(t.FgMuted), + ShortDesc: base.Foreground(t.FgSubtle), + ShortSeparator: base.Foreground(t.Border), + Ellipsis: base.Foreground(t.Border), + FullKey: base.Foreground(t.FgMuted), + FullDesc: base.Foreground(t.FgSubtle), + FullSeparator: base.Foreground(t.Border), + }, + + // TODO: Fix this this is bad + Diff: Diff{ + Added: t.Green, + Removed: t.Red, + Context: t.FgSubtle, + HunkHeader: t.FgSubtle, + HighlightAdded: t.GreenLight, + HighlightRemoved: t.RedLight, + AddedBg: t.GreenDark, + RemovedBg: t.RedDark, + ContextBg: t.BgSubtle, + LineNumber: t.FgMuted, + AddedLineNumberBg: t.GreenDark, + RemovedLineNumberBg: t.RedDark, + }, + + FilePicker: filepicker.Styles{ + DisabledCursor: base.Foreground(t.FgMuted), + Cursor: base.Foreground(t.FgBase), + Symlink: base.Foreground(t.FgSubtle), + Directory: base.Foreground(t.Primary), + File: base.Foreground(t.FgBase), + DisabledFile: base.Foreground(t.FgMuted), + DisabledSelected: base.Background(t.BgOverlay).Foreground(t.FgMuted), + Permission: base.Foreground(t.FgMuted), + Selected: base.Background(t.Primary).Foreground(t.FgBase), + FileSize: base.Foreground(t.FgMuted), + EmptyDirectory: base.Foreground(t.FgMuted).PaddingLeft(2).SetString("Empty directory"), + }, + } +} + +type Manager struct { + themes map[string]*Theme + current *Theme +} + +var defaultManager *Manager + +func SetDefaultManager(m *Manager) { + defaultManager = m +} + +func DefaultManager() *Manager { + if defaultManager == nil { + defaultManager = NewManager("crush") + } + return defaultManager +} + +func CurrentTheme() *Theme { + if defaultManager == nil { + defaultManager = NewManager("crush") + } + return defaultManager.Current() +} + +func NewManager(defaultTheme string) *Manager { + m := &Manager{ + themes: make(map[string]*Theme), + } + + m.Register(NewCrushTheme()) + + m.current = m.themes[defaultTheme] + + return m +} + +func (m *Manager) Register(theme *Theme) { + m.themes[theme.Name] = theme +} + +func (m *Manager) Current() *Theme { + return m.current +} + +func (m *Manager) SetTheme(name string) error { + if theme, ok := m.themes[name]; ok { + m.current = theme + return nil + } + return fmt.Errorf("theme %s not found", name) +} + +func (m *Manager) List() []string { + names := make([]string, 0, len(m.themes)) + for name := range m.themes { + names = append(names, name) + } + return names +} + +// ParseHex converts hex string to color +func ParseHex(hex string) color.Color { + var r, g, b uint8 + fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b) + return color.RGBA{R: r, G: g, B: b, A: 255} +} + +// Alpha returns a color with transparency +func Alpha(c color.Color, alpha uint8) color.Color { + r, g, b, _ := c.RGBA() + return color.RGBA{ + R: uint8(r >> 8), + G: uint8(g >> 8), + B: uint8(b >> 8), + A: alpha, + } +} + +// Darken makes a color darker by percentage (0-100) +func Darken(c color.Color, percent float64) color.Color { + r, g, b, a := c.RGBA() + factor := 1.0 - percent/100.0 + return color.RGBA{ + R: uint8(float64(r>>8) * factor), + G: uint8(float64(g>>8) * factor), + B: uint8(float64(b>>8) * factor), + A: uint8(a >> 8), + } +} + +// Lighten makes a color lighter by percentage (0-100) +func Lighten(c color.Color, percent float64) color.Color { + r, g, b, a := c.RGBA() + factor := percent / 100.0 + return color.RGBA{ + R: uint8(min(255, float64(r>>8)+255*factor)), + G: uint8(min(255, float64(g>>8)+255*factor)), + B: uint8(min(255, float64(b>>8)+255*factor)), + A: uint8(a >> 8), + } +} + +// ApplyForegroundGrad renders a given string with a horizontal gradient +// foreground. +func ApplyForegroundGrad(input string, color1, color2 color.Color) string { + if input == "" { + return "" + } + + var o strings.Builder + if len(input) == 1 { + return lipgloss.NewStyle().Foreground(color1).Render(input) + } + + var clusters []string + gr := uniseg.NewGraphemes(input) + for gr.Next() { + clusters = append(clusters, string(gr.Runes())) + } + + ramp := blendColors(len(clusters), color1, color2) + for i, c := range ramp { + fmt.Fprint(&o, CurrentTheme().S().Base.Foreground(c).Render(clusters[i])) + } + + return o.String() +} + +// blendColors returns a slice of colors blended between the given keys. +// Blending is done in Hcl to stay in gamut. +func blendColors(size int, stops ...color.Color) []color.Color { + if len(stops) < 2 { + return nil + } + + stopsPrime := make([]colorful.Color, len(stops)) + for i, k := range stops { + stopsPrime[i], _ = colorful.MakeColor(k) + } + + numSegments := len(stopsPrime) - 1 + blended := make([]color.Color, 0, size) + + // Calculate how many colors each segment should have. + segmentSizes := make([]int, numSegments) + baseSize := size / numSegments + remainder := size % numSegments + + // Distribute the remainder across segments. + for i := range numSegments { + segmentSizes[i] = baseSize + if i < remainder { + segmentSizes[i]++ + } + } + + // Generate colors for each segment. + for i := range numSegments { + c1 := stopsPrime[i] + c2 := stopsPrime[i+1] + segmentSize := segmentSizes[i] + + for j := range segmentSize { + var t float64 + if segmentSize > 1 { + t = float64(j) / float64(segmentSize-1) + } + c := c1.BlendHcl(c2, t) + blended = append(blended, c) + } + } + + return blended +} diff --git a/internal/tui/theme/catppuccin.go b/internal/tui/theme/catppuccin.go deleted file mode 100644 index a843100ab21c0b3b5b035b67a6322ef2ea5239ef..0000000000000000000000000000000000000000 --- a/internal/tui/theme/catppuccin.go +++ /dev/null @@ -1,248 +0,0 @@ -package theme - -import ( - catppuccin "github.com/catppuccin/go" - "github.com/charmbracelet/lipgloss" -) - -// CatppuccinTheme implements the Theme interface with Catppuccin colors. -// It provides both dark (Mocha) and light (Latte) variants. -type CatppuccinTheme struct { - BaseTheme -} - -// NewCatppuccinTheme creates a new instance of the Catppuccin theme. -func NewCatppuccinTheme() *CatppuccinTheme { - // Get the Catppuccin palettes - mocha := catppuccin.Mocha - latte := catppuccin.Latte - - theme := &CatppuccinTheme{} - - // Base colors - theme.PrimaryColor = lipgloss.AdaptiveColor{ - Dark: mocha.Blue().Hex, - Light: latte.Blue().Hex, - } - theme.SecondaryColor = lipgloss.AdaptiveColor{ - Dark: mocha.Mauve().Hex, - Light: latte.Mauve().Hex, - } - theme.AccentColor = lipgloss.AdaptiveColor{ - Dark: mocha.Peach().Hex, - Light: latte.Peach().Hex, - } - - // Status colors - theme.ErrorColor = lipgloss.AdaptiveColor{ - Dark: mocha.Red().Hex, - Light: latte.Red().Hex, - } - theme.WarningColor = lipgloss.AdaptiveColor{ - Dark: mocha.Peach().Hex, - Light: latte.Peach().Hex, - } - theme.SuccessColor = lipgloss.AdaptiveColor{ - Dark: mocha.Green().Hex, - Light: latte.Green().Hex, - } - theme.InfoColor = lipgloss.AdaptiveColor{ - Dark: mocha.Blue().Hex, - Light: latte.Blue().Hex, - } - - // Text colors - theme.TextColor = lipgloss.AdaptiveColor{ - Dark: mocha.Text().Hex, - Light: latte.Text().Hex, - } - theme.TextMutedColor = lipgloss.AdaptiveColor{ - Dark: mocha.Subtext0().Hex, - Light: latte.Subtext0().Hex, - } - theme.TextEmphasizedColor = lipgloss.AdaptiveColor{ - Dark: mocha.Lavender().Hex, - Light: latte.Lavender().Hex, - } - - // Background colors - theme.BackgroundColor = lipgloss.AdaptiveColor{ - Dark: "#212121", // From existing styles - Light: "#EEEEEE", // Light equivalent - } - theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ - Dark: "#2c2c2c", // From existing styles - Light: "#E0E0E0", // Light equivalent - } - theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ - Dark: "#181818", // From existing styles - Light: "#F5F5F5", // Light equivalent - } - - // Border colors - theme.BorderNormalColor = lipgloss.AdaptiveColor{ - Dark: "#4b4c5c", // From existing styles - Light: "#BDBDBD", // Light equivalent - } - theme.BorderFocusedColor = lipgloss.AdaptiveColor{ - Dark: mocha.Blue().Hex, - Light: latte.Blue().Hex, - } - theme.BorderDimColor = lipgloss.AdaptiveColor{ - Dark: mocha.Surface0().Hex, - Light: latte.Surface0().Hex, - } - - // Diff view colors - theme.DiffAddedColor = lipgloss.AdaptiveColor{ - Dark: "#478247", // From existing diff.go - Light: "#2E7D32", // Light equivalent - } - theme.DiffRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#7C4444", // From existing diff.go - Light: "#C62828", // Light equivalent - } - theme.DiffContextColor = lipgloss.AdaptiveColor{ - Dark: "#a0a0a0", // From existing diff.go - Light: "#757575", // Light equivalent - } - theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ - Dark: "#a0a0a0", // From existing diff.go - Light: "#757575", // Light equivalent - } - theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ - Dark: "#DAFADA", // From existing diff.go - Light: "#A5D6A7", // Light equivalent - } - theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#FADADD", // From existing diff.go - Light: "#EF9A9A", // Light equivalent - } - theme.DiffAddedBgColor = lipgloss.AdaptiveColor{ - Dark: "#303A30", // From existing diff.go - Light: "#E8F5E9", // Light equivalent - } - theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{ - Dark: "#3A3030", // From existing diff.go - Light: "#FFEBEE", // Light equivalent - } - theme.DiffContextBgColor = lipgloss.AdaptiveColor{ - Dark: "#212121", // From existing diff.go - Light: "#F5F5F5", // Light equivalent - } - theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ - Dark: "#888888", // From existing diff.go - Light: "#9E9E9E", // Light equivalent - } - theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#293229", // From existing diff.go - Light: "#C8E6C9", // Light equivalent - } - theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#332929", // From existing diff.go - Light: "#FFCDD2", // Light equivalent - } - - // Markdown colors - theme.MarkdownTextColor = lipgloss.AdaptiveColor{ - Dark: mocha.Text().Hex, - Light: latte.Text().Hex, - } - theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ - Dark: mocha.Mauve().Hex, - Light: latte.Mauve().Hex, - } - theme.MarkdownLinkColor = lipgloss.AdaptiveColor{ - Dark: mocha.Sky().Hex, - Light: latte.Sky().Hex, - } - theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{ - Dark: mocha.Pink().Hex, - Light: latte.Pink().Hex, - } - theme.MarkdownCodeColor = lipgloss.AdaptiveColor{ - Dark: mocha.Green().Hex, - Light: latte.Green().Hex, - } - theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{ - Dark: mocha.Yellow().Hex, - Light: latte.Yellow().Hex, - } - theme.MarkdownEmphColor = lipgloss.AdaptiveColor{ - Dark: mocha.Yellow().Hex, - Light: latte.Yellow().Hex, - } - theme.MarkdownStrongColor = lipgloss.AdaptiveColor{ - Dark: mocha.Peach().Hex, - Light: latte.Peach().Hex, - } - theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ - Dark: mocha.Overlay0().Hex, - Light: latte.Overlay0().Hex, - } - theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ - Dark: mocha.Blue().Hex, - Light: latte.Blue().Hex, - } - theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{ - Dark: mocha.Sky().Hex, - Light: latte.Sky().Hex, - } - theme.MarkdownImageColor = lipgloss.AdaptiveColor{ - Dark: mocha.Sapphire().Hex, - Light: latte.Sapphire().Hex, - } - theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{ - Dark: mocha.Pink().Hex, - Light: latte.Pink().Hex, - } - theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ - Dark: mocha.Text().Hex, - Light: latte.Text().Hex, - } - - // Syntax highlighting colors - theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ - Dark: mocha.Overlay1().Hex, - Light: latte.Overlay1().Hex, - } - theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ - Dark: mocha.Pink().Hex, - Light: latte.Pink().Hex, - } - theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{ - Dark: mocha.Green().Hex, - Light: latte.Green().Hex, - } - theme.SyntaxVariableColor = lipgloss.AdaptiveColor{ - Dark: mocha.Sky().Hex, - Light: latte.Sky().Hex, - } - theme.SyntaxStringColor = lipgloss.AdaptiveColor{ - Dark: mocha.Yellow().Hex, - Light: latte.Yellow().Hex, - } - theme.SyntaxNumberColor = lipgloss.AdaptiveColor{ - Dark: mocha.Teal().Hex, - Light: latte.Teal().Hex, - } - theme.SyntaxTypeColor = lipgloss.AdaptiveColor{ - Dark: mocha.Sky().Hex, - Light: latte.Sky().Hex, - } - theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{ - Dark: mocha.Pink().Hex, - Light: latte.Pink().Hex, - } - theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ - Dark: mocha.Text().Hex, - Light: latte.Text().Hex, - } - - return theme -} - -func init() { - // Register the Catppuccin theme with the theme manager - RegisterTheme("catppuccin", NewCatppuccinTheme()) -} \ No newline at end of file diff --git a/internal/tui/theme/dracula.go b/internal/tui/theme/dracula.go deleted file mode 100644 index e625206ae5e0470081244fa306443bbdd81a0b93..0000000000000000000000000000000000000000 --- a/internal/tui/theme/dracula.go +++ /dev/null @@ -1,274 +0,0 @@ -package theme - -import ( - "github.com/charmbracelet/lipgloss" -) - -// DraculaTheme implements the Theme interface with Dracula colors. -// It provides both dark and light variants, though Dracula is primarily a dark theme. -type DraculaTheme struct { - BaseTheme -} - -// NewDraculaTheme creates a new instance of the Dracula theme. -func NewDraculaTheme() *DraculaTheme { - // Dracula color palette - // Official colors from https://draculatheme.com/ - darkBackground := "#282a36" - darkCurrentLine := "#44475a" - darkSelection := "#44475a" - darkForeground := "#f8f8f2" - darkComment := "#6272a4" - darkCyan := "#8be9fd" - darkGreen := "#50fa7b" - darkOrange := "#ffb86c" - darkPink := "#ff79c6" - darkPurple := "#bd93f9" - darkRed := "#ff5555" - darkYellow := "#f1fa8c" - darkBorder := "#44475a" - - // Light mode approximation (Dracula is primarily a dark theme) - lightBackground := "#f8f8f2" - lightCurrentLine := "#e6e6e6" - lightSelection := "#d8d8d8" - lightForeground := "#282a36" - lightComment := "#6272a4" - lightCyan := "#0097a7" - lightGreen := "#388e3c" - lightOrange := "#f57c00" - lightPink := "#d81b60" - lightPurple := "#7e57c2" - lightRed := "#e53935" - lightYellow := "#fbc02d" - lightBorder := "#d8d8d8" - - theme := &DraculaTheme{} - - // Base colors - theme.PrimaryColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.SecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkPink, - Light: lightPink, - } - theme.AccentColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - - // Status colors - theme.ErrorColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.WarningColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SuccessColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.InfoColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - - // Text colors - theme.TextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.TextMutedColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.TextEmphasizedColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - - // Background colors - theme.BackgroundColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkCurrentLine, - Light: lightCurrentLine, - } - theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ - Dark: "#21222c", // Slightly darker than background - Light: "#ffffff", // Slightly lighter than background - } - - // Border colors - theme.BorderNormalColor = lipgloss.AdaptiveColor{ - Dark: darkBorder, - Light: lightBorder, - } - theme.BorderFocusedColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.BorderDimColor = lipgloss.AdaptiveColor{ - Dark: darkSelection, - Light: lightSelection, - } - - // Diff view colors - theme.DiffAddedColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.DiffRemovedColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.DiffContextColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ - Dark: "#50fa7b", - Light: "#a5d6a7", - } - theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#ff5555", - Light: "#ef9a9a", - } - theme.DiffAddedBgColor = lipgloss.AdaptiveColor{ - Dark: "#2c3b2c", - Light: "#e8f5e9", - } - theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{ - Dark: "#3b2c2c", - Light: "#ffebee", - } - theme.DiffContextBgColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#253025", - Light: "#c8e6c9", - } - theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#302525", - Light: "#ffcdd2", - } - - // Markdown colors - theme.MarkdownTextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ - Dark: darkPink, - Light: lightPink, - } - theme.MarkdownLinkColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownEmphColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownStrongColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownImageColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - // Syntax highlighting colors - theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ - Dark: darkPink, - Light: lightPink, - } - theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.SyntaxVariableColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SyntaxStringColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.SyntaxNumberColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.SyntaxTypeColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{ - Dark: darkPink, - Light: lightPink, - } - theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - return theme -} - -func init() { - // Register the Dracula theme with the theme manager - RegisterTheme("dracula", NewDraculaTheme()) -} \ No newline at end of file diff --git a/internal/tui/theme/flexoki.go b/internal/tui/theme/flexoki.go deleted file mode 100644 index 49d94beb15656775d7f79679391aa6589fb18473..0000000000000000000000000000000000000000 --- a/internal/tui/theme/flexoki.go +++ /dev/null @@ -1,282 +0,0 @@ -package theme - -import ( - "github.com/charmbracelet/lipgloss" -) - -// Flexoki color palette constants -const ( - // Base colors - flexokiPaper = "#FFFCF0" // Paper (lightest) - flexokiBase50 = "#F2F0E5" // bg-2 (light) - flexokiBase100 = "#E6E4D9" // ui (light) - flexokiBase150 = "#DAD8CE" // ui-2 (light) - flexokiBase200 = "#CECDC3" // ui-3 (light) - flexokiBase300 = "#B7B5AC" // tx-3 (light) - flexokiBase500 = "#878580" // tx-2 (light) - flexokiBase600 = "#6F6E69" // tx (light) - flexokiBase700 = "#575653" // tx-3 (dark) - flexokiBase800 = "#403E3C" // ui-3 (dark) - flexokiBase850 = "#343331" // ui-2 (dark) - flexokiBase900 = "#282726" // ui (dark) - flexokiBase950 = "#1C1B1A" // bg-2 (dark) - flexokiBlack = "#100F0F" // bg (darkest) - - // Accent colors - Light theme (600) - flexokiRed600 = "#AF3029" - flexokiOrange600 = "#BC5215" - flexokiYellow600 = "#AD8301" - flexokiGreen600 = "#66800B" - flexokiCyan600 = "#24837B" - flexokiBlue600 = "#205EA6" - flexokiPurple600 = "#5E409D" - flexokiMagenta600 = "#A02F6F" - - // Accent colors - Dark theme (400) - flexokiRed400 = "#D14D41" - flexokiOrange400 = "#DA702C" - flexokiYellow400 = "#D0A215" - flexokiGreen400 = "#879A39" - flexokiCyan400 = "#3AA99F" - flexokiBlue400 = "#4385BE" - flexokiPurple400 = "#8B7EC8" - flexokiMagenta400 = "#CE5D97" -) - -// FlexokiTheme implements the Theme interface with Flexoki colors. -// It provides both dark and light variants. -type FlexokiTheme struct { - BaseTheme -} - -// NewFlexokiTheme creates a new instance of the Flexoki theme. -func NewFlexokiTheme() *FlexokiTheme { - theme := &FlexokiTheme{} - - // Base colors - theme.PrimaryColor = lipgloss.AdaptiveColor{ - Dark: flexokiBlue400, - Light: flexokiBlue600, - } - theme.SecondaryColor = lipgloss.AdaptiveColor{ - Dark: flexokiPurple400, - Light: flexokiPurple600, - } - theme.AccentColor = lipgloss.AdaptiveColor{ - Dark: flexokiOrange400, - Light: flexokiOrange600, - } - - // Status colors - theme.ErrorColor = lipgloss.AdaptiveColor{ - Dark: flexokiRed400, - Light: flexokiRed600, - } - theme.WarningColor = lipgloss.AdaptiveColor{ - Dark: flexokiYellow400, - Light: flexokiYellow600, - } - theme.SuccessColor = lipgloss.AdaptiveColor{ - Dark: flexokiGreen400, - Light: flexokiGreen600, - } - theme.InfoColor = lipgloss.AdaptiveColor{ - Dark: flexokiCyan400, - Light: flexokiCyan600, - } - - // Text colors - theme.TextColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase300, - Light: flexokiBase600, - } - theme.TextMutedColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase700, - Light: flexokiBase500, - } - theme.TextEmphasizedColor = lipgloss.AdaptiveColor{ - Dark: flexokiYellow400, - Light: flexokiYellow600, - } - - // Background colors - theme.BackgroundColor = lipgloss.AdaptiveColor{ - Dark: flexokiBlack, - Light: flexokiPaper, - } - theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase950, - Light: flexokiBase50, - } - theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase900, - Light: flexokiBase100, - } - - // Border colors - theme.BorderNormalColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase900, - Light: flexokiBase100, - } - theme.BorderFocusedColor = lipgloss.AdaptiveColor{ - Dark: flexokiBlue400, - Light: flexokiBlue600, - } - theme.BorderDimColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase850, - Light: flexokiBase150, - } - - // Diff view colors - theme.DiffAddedColor = lipgloss.AdaptiveColor{ - Dark: flexokiGreen400, - Light: flexokiGreen600, - } - theme.DiffRemovedColor = lipgloss.AdaptiveColor{ - Dark: flexokiRed400, - Light: flexokiRed600, - } - theme.DiffContextColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase700, - Light: flexokiBase500, - } - theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase700, - Light: flexokiBase500, - } - theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ - Dark: flexokiGreen400, - Light: flexokiGreen600, - } - theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{ - Dark: flexokiRed400, - Light: flexokiRed600, - } - theme.DiffAddedBgColor = lipgloss.AdaptiveColor{ - Dark: "#1D2419", // Darker green background - Light: "#EFF2E2", // Light green background - } - theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{ - Dark: "#241919", // Darker red background - Light: "#F2E2E2", // Light red background - } - theme.DiffContextBgColor = lipgloss.AdaptiveColor{ - Dark: flexokiBlack, - Light: flexokiPaper, - } - theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase700, - Light: flexokiBase500, - } - theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#1A2017", // Slightly darker green - Light: "#E5EBD9", // Light green - } - theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#201717", // Slightly darker red - Light: "#EBD9D9", // Light red - } - - // Markdown colors - theme.MarkdownTextColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase300, - Light: flexokiBase600, - } - theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ - Dark: flexokiYellow400, - Light: flexokiYellow600, - } - theme.MarkdownLinkColor = lipgloss.AdaptiveColor{ - Dark: flexokiCyan400, - Light: flexokiCyan600, - } - theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{ - Dark: flexokiMagenta400, - Light: flexokiMagenta600, - } - theme.MarkdownCodeColor = lipgloss.AdaptiveColor{ - Dark: flexokiGreen400, - Light: flexokiGreen600, - } - theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{ - Dark: flexokiCyan400, - Light: flexokiCyan600, - } - theme.MarkdownEmphColor = lipgloss.AdaptiveColor{ - Dark: flexokiYellow400, - Light: flexokiYellow600, - } - theme.MarkdownStrongColor = lipgloss.AdaptiveColor{ - Dark: flexokiOrange400, - Light: flexokiOrange600, - } - theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase800, - Light: flexokiBase200, - } - theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ - Dark: flexokiBlue400, - Light: flexokiBlue600, - } - theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{ - Dark: flexokiBlue400, - Light: flexokiBlue600, - } - theme.MarkdownImageColor = lipgloss.AdaptiveColor{ - Dark: flexokiPurple400, - Light: flexokiPurple600, - } - theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{ - Dark: flexokiMagenta400, - Light: flexokiMagenta600, - } - theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase300, - Light: flexokiBase600, - } - - // Syntax highlighting colors (based on Flexoki's mappings) - theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase700, // tx-3 - Light: flexokiBase300, // tx-3 - } - theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ - Dark: flexokiGreen400, // gr - Light: flexokiGreen600, // gr - } - theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{ - Dark: flexokiOrange400, // or - Light: flexokiOrange600, // or - } - theme.SyntaxVariableColor = lipgloss.AdaptiveColor{ - Dark: flexokiBlue400, // bl - Light: flexokiBlue600, // bl - } - theme.SyntaxStringColor = lipgloss.AdaptiveColor{ - Dark: flexokiCyan400, // cy - Light: flexokiCyan600, // cy - } - theme.SyntaxNumberColor = lipgloss.AdaptiveColor{ - Dark: flexokiPurple400, // pu - Light: flexokiPurple600, // pu - } - theme.SyntaxTypeColor = lipgloss.AdaptiveColor{ - Dark: flexokiYellow400, // ye - Light: flexokiYellow600, // ye - } - theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase500, // tx-2 - Light: flexokiBase500, // tx-2 - } - theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ - Dark: flexokiBase500, // tx-2 - Light: flexokiBase500, // tx-2 - } - - return theme -} - -func init() { - // Register the Flexoki theme with the theme manager - RegisterTheme("flexoki", NewFlexokiTheme()) -} \ No newline at end of file diff --git a/internal/tui/theme/gruvbox.go b/internal/tui/theme/gruvbox.go deleted file mode 100644 index ed544b84de80446bf0f90be40adbc35cd0ba0689..0000000000000000000000000000000000000000 --- a/internal/tui/theme/gruvbox.go +++ /dev/null @@ -1,302 +0,0 @@ -package theme - -import ( - "github.com/charmbracelet/lipgloss" -) - -// Gruvbox color palette constants -const ( - // Dark theme colors - gruvboxDarkBg0 = "#282828" - gruvboxDarkBg0Soft = "#32302f" - gruvboxDarkBg1 = "#3c3836" - gruvboxDarkBg2 = "#504945" - gruvboxDarkBg3 = "#665c54" - gruvboxDarkBg4 = "#7c6f64" - gruvboxDarkFg0 = "#fbf1c7" - gruvboxDarkFg1 = "#ebdbb2" - gruvboxDarkFg2 = "#d5c4a1" - gruvboxDarkFg3 = "#bdae93" - gruvboxDarkFg4 = "#a89984" - gruvboxDarkGray = "#928374" - gruvboxDarkRed = "#cc241d" - gruvboxDarkRedBright = "#fb4934" - gruvboxDarkGreen = "#98971a" - gruvboxDarkGreenBright = "#b8bb26" - gruvboxDarkYellow = "#d79921" - gruvboxDarkYellowBright = "#fabd2f" - gruvboxDarkBlue = "#458588" - gruvboxDarkBlueBright = "#83a598" - gruvboxDarkPurple = "#b16286" - gruvboxDarkPurpleBright = "#d3869b" - gruvboxDarkAqua = "#689d6a" - gruvboxDarkAquaBright = "#8ec07c" - gruvboxDarkOrange = "#d65d0e" - gruvboxDarkOrangeBright = "#fe8019" - - // Light theme colors - gruvboxLightBg0 = "#fbf1c7" - gruvboxLightBg0Soft = "#f2e5bc" - gruvboxLightBg1 = "#ebdbb2" - gruvboxLightBg2 = "#d5c4a1" - gruvboxLightBg3 = "#bdae93" - gruvboxLightBg4 = "#a89984" - gruvboxLightFg0 = "#282828" - gruvboxLightFg1 = "#3c3836" - gruvboxLightFg2 = "#504945" - gruvboxLightFg3 = "#665c54" - gruvboxLightFg4 = "#7c6f64" - gruvboxLightGray = "#928374" - gruvboxLightRed = "#9d0006" - gruvboxLightRedBright = "#cc241d" - gruvboxLightGreen = "#79740e" - gruvboxLightGreenBright = "#98971a" - gruvboxLightYellow = "#b57614" - gruvboxLightYellowBright = "#d79921" - gruvboxLightBlue = "#076678" - gruvboxLightBlueBright = "#458588" - gruvboxLightPurple = "#8f3f71" - gruvboxLightPurpleBright = "#b16286" - gruvboxLightAqua = "#427b58" - gruvboxLightAquaBright = "#689d6a" - gruvboxLightOrange = "#af3a03" - gruvboxLightOrangeBright = "#d65d0e" -) - -// GruvboxTheme implements the Theme interface with Gruvbox colors. -// It provides both dark and light variants. -type GruvboxTheme struct { - BaseTheme -} - -// NewGruvboxTheme creates a new instance of the Gruvbox theme. -func NewGruvboxTheme() *GruvboxTheme { - theme := &GruvboxTheme{} - - // Base colors - theme.PrimaryColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBlueBright, - Light: gruvboxLightBlueBright, - } - theme.SecondaryColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkPurpleBright, - Light: gruvboxLightPurpleBright, - } - theme.AccentColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkOrangeBright, - Light: gruvboxLightOrangeBright, - } - - // Status colors - theme.ErrorColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkRedBright, - Light: gruvboxLightRedBright, - } - theme.WarningColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkYellowBright, - Light: gruvboxLightYellowBright, - } - theme.SuccessColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkGreenBright, - Light: gruvboxLightGreenBright, - } - theme.InfoColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBlueBright, - Light: gruvboxLightBlueBright, - } - - // Text colors - theme.TextColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkFg1, - Light: gruvboxLightFg1, - } - theme.TextMutedColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkFg4, - Light: gruvboxLightFg4, - } - theme.TextEmphasizedColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkYellowBright, - Light: gruvboxLightYellowBright, - } - - // Background colors - theme.BackgroundColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBg0, - Light: gruvboxLightBg0, - } - theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBg1, - Light: gruvboxLightBg1, - } - theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBg0Soft, - Light: gruvboxLightBg0Soft, - } - - // Border colors - theme.BorderNormalColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBg2, - Light: gruvboxLightBg2, - } - theme.BorderFocusedColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBlueBright, - Light: gruvboxLightBlueBright, - } - theme.BorderDimColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBg1, - Light: gruvboxLightBg1, - } - - // Diff view colors - theme.DiffAddedColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkGreenBright, - Light: gruvboxLightGreenBright, - } - theme.DiffRemovedColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkRedBright, - Light: gruvboxLightRedBright, - } - theme.DiffContextColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkFg4, - Light: gruvboxLightFg4, - } - theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkFg3, - Light: gruvboxLightFg3, - } - theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkGreenBright, - Light: gruvboxLightGreenBright, - } - theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkRedBright, - Light: gruvboxLightRedBright, - } - theme.DiffAddedBgColor = lipgloss.AdaptiveColor{ - Dark: "#3C4C3C", // Darker green background - Light: "#E8F5E9", // Light green background - } - theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{ - Dark: "#4C3C3C", // Darker red background - Light: "#FFEBEE", // Light red background - } - theme.DiffContextBgColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBg0, - Light: gruvboxLightBg0, - } - theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkFg4, - Light: gruvboxLightFg4, - } - theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#32432F", // Slightly darker green - Light: "#C8E6C9", // Light green - } - theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#43322F", // Slightly darker red - Light: "#FFCDD2", // Light red - } - - // Markdown colors - theme.MarkdownTextColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkFg1, - Light: gruvboxLightFg1, - } - theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkYellowBright, - Light: gruvboxLightYellowBright, - } - theme.MarkdownLinkColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBlueBright, - Light: gruvboxLightBlueBright, - } - theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkAquaBright, - Light: gruvboxLightAquaBright, - } - theme.MarkdownCodeColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkGreenBright, - Light: gruvboxLightGreenBright, - } - theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkAquaBright, - Light: gruvboxLightAquaBright, - } - theme.MarkdownEmphColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkYellowBright, - Light: gruvboxLightYellowBright, - } - theme.MarkdownStrongColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkOrangeBright, - Light: gruvboxLightOrangeBright, - } - theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBg3, - Light: gruvboxLightBg3, - } - theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBlueBright, - Light: gruvboxLightBlueBright, - } - theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBlueBright, - Light: gruvboxLightBlueBright, - } - theme.MarkdownImageColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkPurpleBright, - Light: gruvboxLightPurpleBright, - } - theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkAquaBright, - Light: gruvboxLightAquaBright, - } - theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkFg1, - Light: gruvboxLightFg1, - } - - // Syntax highlighting colors - theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkGray, - Light: gruvboxLightGray, - } - theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkRedBright, - Light: gruvboxLightRedBright, - } - theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkGreenBright, - Light: gruvboxLightGreenBright, - } - theme.SyntaxVariableColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkBlueBright, - Light: gruvboxLightBlueBright, - } - theme.SyntaxStringColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkYellowBright, - Light: gruvboxLightYellowBright, - } - theme.SyntaxNumberColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkPurpleBright, - Light: gruvboxLightPurpleBright, - } - theme.SyntaxTypeColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkYellow, - Light: gruvboxLightYellow, - } - theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkAquaBright, - Light: gruvboxLightAquaBright, - } - theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ - Dark: gruvboxDarkFg1, - Light: gruvboxLightFg1, - } - - return theme -} - -func init() { - // Register the Gruvbox theme with the theme manager - RegisterTheme("gruvbox", NewGruvboxTheme()) -} \ No newline at end of file diff --git a/internal/tui/theme/manager.go b/internal/tui/theme/manager.go deleted file mode 100644 index a81ba45c12db73823f41fa416778895046dd5ec7..0000000000000000000000000000000000000000 --- a/internal/tui/theme/manager.go +++ /dev/null @@ -1,118 +0,0 @@ -package theme - -import ( - "fmt" - "slices" - "strings" - "sync" - - "github.com/alecthomas/chroma/v2/styles" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/logging" -) - -// Manager handles theme registration, selection, and retrieval. -// It maintains a registry of available themes and tracks the currently active theme. -type Manager struct { - themes map[string]Theme - currentName string - mu sync.RWMutex -} - -// Global instance of the theme manager -var globalManager = &Manager{ - themes: make(map[string]Theme), - currentName: "", -} - -// RegisterTheme adds a new theme to the registry. -// If this is the first theme registered, it becomes the default. -func RegisterTheme(name string, theme Theme) { - globalManager.mu.Lock() - defer globalManager.mu.Unlock() - - globalManager.themes[name] = theme - - // If this is the first theme, make it the default - if globalManager.currentName == "" { - globalManager.currentName = name - } -} - -// SetTheme changes the active theme to the one with the specified name. -// Returns an error if the theme doesn't exist. -func SetTheme(name string) error { - globalManager.mu.Lock() - defer globalManager.mu.Unlock() - - delete(styles.Registry, "charm") - if _, exists := globalManager.themes[name]; !exists { - return fmt.Errorf("theme '%s' not found", name) - } - - globalManager.currentName = name - - // Update the config file using viper - if err := updateConfigTheme(name); err != nil { - // Log the error but don't fail the theme change - logging.Warn("Warning: Failed to update config file with new theme", "err", err) - } - - return nil -} - -// CurrentTheme returns the currently active theme. -// If no theme is set, it returns nil. -func CurrentTheme() Theme { - globalManager.mu.RLock() - defer globalManager.mu.RUnlock() - - if globalManager.currentName == "" { - return nil - } - - return globalManager.themes[globalManager.currentName] -} - -// CurrentThemeName returns the name of the currently active theme. -func CurrentThemeName() string { - globalManager.mu.RLock() - defer globalManager.mu.RUnlock() - - return globalManager.currentName -} - -// AvailableThemes returns a list of all registered theme names. -func AvailableThemes() []string { - globalManager.mu.RLock() - defer globalManager.mu.RUnlock() - - names := make([]string, 0, len(globalManager.themes)) - for name := range globalManager.themes { - names = append(names, name) - } - slices.SortFunc(names, func(a, b string) int { - if a == "opencode" { - return -1 - } else if b == "opencode" { - return 1 - } - return strings.Compare(a, b) - }) - return names -} - -// GetTheme returns a specific theme by name. -// Returns nil if the theme doesn't exist. -func GetTheme(name string) Theme { - globalManager.mu.RLock() - defer globalManager.mu.RUnlock() - - return globalManager.themes[name] -} - -// updateConfigTheme updates the theme setting in the configuration file -func updateConfigTheme(themeName string) error { - // Use the config package to update the theme - return config.UpdateTheme(themeName) -} diff --git a/internal/tui/theme/monokai.go b/internal/tui/theme/monokai.go deleted file mode 100644 index 4695fefa998f0e038442b1bea3074f5a8a808a0e..0000000000000000000000000000000000000000 --- a/internal/tui/theme/monokai.go +++ /dev/null @@ -1,273 +0,0 @@ -package theme - -import ( - "github.com/charmbracelet/lipgloss" -) - -// MonokaiProTheme implements the Theme interface with Monokai Pro colors. -// It provides both dark and light variants. -type MonokaiProTheme struct { - BaseTheme -} - -// NewMonokaiProTheme creates a new instance of the Monokai Pro theme. -func NewMonokaiProTheme() *MonokaiProTheme { - // Monokai Pro color palette (dark mode) - darkBackground := "#2d2a2e" - darkCurrentLine := "#403e41" - darkSelection := "#5b595c" - darkForeground := "#fcfcfa" - darkComment := "#727072" - darkRed := "#ff6188" - darkOrange := "#fc9867" - darkYellow := "#ffd866" - darkGreen := "#a9dc76" - darkCyan := "#78dce8" - darkBlue := "#ab9df2" - darkPurple := "#ab9df2" - darkBorder := "#403e41" - - // Light mode colors (adapted from dark) - lightBackground := "#fafafa" - lightCurrentLine := "#f0f0f0" - lightSelection := "#e5e5e6" - lightForeground := "#2d2a2e" - lightComment := "#939293" - lightRed := "#f92672" - lightOrange := "#fd971f" - lightYellow := "#e6db74" - lightGreen := "#9bca65" - lightCyan := "#66d9ef" - lightBlue := "#7e75db" - lightPurple := "#ae81ff" - lightBorder := "#d3d3d3" - - theme := &MonokaiProTheme{} - - // Base colors - theme.PrimaryColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.SecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.AccentColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - - // Status colors - theme.ErrorColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.WarningColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SuccessColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.InfoColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - - // Text colors - theme.TextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.TextMutedColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.TextEmphasizedColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - - // Background colors - theme.BackgroundColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkCurrentLine, - Light: lightCurrentLine, - } - theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ - Dark: "#221f22", // Slightly darker than background - Light: "#ffffff", // Slightly lighter than background - } - - // Border colors - theme.BorderNormalColor = lipgloss.AdaptiveColor{ - Dark: darkBorder, - Light: lightBorder, - } - theme.BorderFocusedColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.BorderDimColor = lipgloss.AdaptiveColor{ - Dark: darkSelection, - Light: lightSelection, - } - - // Diff view colors - theme.DiffAddedColor = lipgloss.AdaptiveColor{ - Dark: "#a9dc76", - Light: "#9bca65", - } - theme.DiffRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#ff6188", - Light: "#f92672", - } - theme.DiffContextColor = lipgloss.AdaptiveColor{ - Dark: "#a0a0a0", - Light: "#757575", - } - theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ - Dark: "#a0a0a0", - Light: "#757575", - } - theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ - Dark: "#c2e7a9", - Light: "#c5e0b4", - } - theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#ff8ca6", - Light: "#ffb3c8", - } - theme.DiffAddedBgColor = lipgloss.AdaptiveColor{ - Dark: "#3a4a35", - Light: "#e8f5e9", - } - theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{ - Dark: "#4a3439", - Light: "#ffebee", - } - theme.DiffContextBgColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ - Dark: "#888888", - Light: "#9e9e9e", - } - theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#2d3a28", - Light: "#c8e6c9", - } - theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#3d2a2e", - Light: "#ffcdd2", - } - - // Markdown colors - theme.MarkdownTextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.MarkdownLinkColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownCodeColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownEmphColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownStrongColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownImageColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - // Syntax highlighting colors - theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.SyntaxVariableColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.SyntaxStringColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.SyntaxNumberColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.SyntaxTypeColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - return theme -} - -func init() { - // Register the Monokai Pro theme with the theme manager - RegisterTheme("monokai", NewMonokaiProTheme()) -} \ No newline at end of file diff --git a/internal/tui/theme/onedark.go b/internal/tui/theme/onedark.go deleted file mode 100644 index 2b4dee50dccdd9639e0245c3d4eaaabb798b0cfe..0000000000000000000000000000000000000000 --- a/internal/tui/theme/onedark.go +++ /dev/null @@ -1,274 +0,0 @@ -package theme - -import ( - "github.com/charmbracelet/lipgloss" -) - -// OneDarkTheme implements the Theme interface with Atom's One Dark colors. -// It provides both dark and light variants. -type OneDarkTheme struct { - BaseTheme -} - -// NewOneDarkTheme creates a new instance of the One Dark theme. -func NewOneDarkTheme() *OneDarkTheme { - // One Dark color palette - // Dark mode colors from Atom One Dark - darkBackground := "#282c34" - darkCurrentLine := "#2c313c" - darkSelection := "#3e4451" - darkForeground := "#abb2bf" - darkComment := "#5c6370" - darkRed := "#e06c75" - darkOrange := "#d19a66" - darkYellow := "#e5c07b" - darkGreen := "#98c379" - darkCyan := "#56b6c2" - darkBlue := "#61afef" - darkPurple := "#c678dd" - darkBorder := "#3b4048" - - // Light mode colors from Atom One Light - lightBackground := "#fafafa" - lightCurrentLine := "#f0f0f0" - lightSelection := "#e5e5e6" - lightForeground := "#383a42" - lightComment := "#a0a1a7" - lightRed := "#e45649" - lightOrange := "#da8548" - lightYellow := "#c18401" - lightGreen := "#50a14f" - lightCyan := "#0184bc" - lightBlue := "#4078f2" - lightPurple := "#a626a4" - lightBorder := "#d3d3d3" - - theme := &OneDarkTheme{} - - // Base colors - theme.PrimaryColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.SecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.AccentColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - - // Status colors - theme.ErrorColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.WarningColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SuccessColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.InfoColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - - // Text colors - theme.TextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.TextMutedColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.TextEmphasizedColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - - // Background colors - theme.BackgroundColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkCurrentLine, - Light: lightCurrentLine, - } - theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ - Dark: "#21252b", // Slightly darker than background - Light: "#ffffff", // Slightly lighter than background - } - - // Border colors - theme.BorderNormalColor = lipgloss.AdaptiveColor{ - Dark: darkBorder, - Light: lightBorder, - } - theme.BorderFocusedColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.BorderDimColor = lipgloss.AdaptiveColor{ - Dark: darkSelection, - Light: lightSelection, - } - - // Diff view colors - theme.DiffAddedColor = lipgloss.AdaptiveColor{ - Dark: "#478247", - Light: "#2E7D32", - } - theme.DiffRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#7C4444", - Light: "#C62828", - } - theme.DiffContextColor = lipgloss.AdaptiveColor{ - Dark: "#a0a0a0", - Light: "#757575", - } - theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ - Dark: "#a0a0a0", - Light: "#757575", - } - theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ - Dark: "#DAFADA", - Light: "#A5D6A7", - } - theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#FADADD", - Light: "#EF9A9A", - } - theme.DiffAddedBgColor = lipgloss.AdaptiveColor{ - Dark: "#303A30", - Light: "#E8F5E9", - } - theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{ - Dark: "#3A3030", - Light: "#FFEBEE", - } - theme.DiffContextBgColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ - Dark: "#888888", - Light: "#9E9E9E", - } - theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#293229", - Light: "#C8E6C9", - } - theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#332929", - Light: "#FFCDD2", - } - - // Markdown colors - theme.MarkdownTextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.MarkdownLinkColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownEmphColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownStrongColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownImageColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - // Syntax highlighting colors - theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.SyntaxVariableColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.SyntaxStringColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.SyntaxNumberColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SyntaxTypeColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - return theme -} - -func init() { - // Register the One Dark theme with the theme manager - RegisterTheme("onedark", NewOneDarkTheme()) -} \ No newline at end of file diff --git a/internal/tui/theme/opencode.go b/internal/tui/theme/opencode.go deleted file mode 100644 index efec8615437eef1516582b2492833cfa16ffe4d8..0000000000000000000000000000000000000000 --- a/internal/tui/theme/opencode.go +++ /dev/null @@ -1,277 +0,0 @@ -package theme - -import ( - "github.com/charmbracelet/lipgloss" -) - -// OpenCodeTheme implements the Theme interface with OpenCode brand colors. -// It provides both dark and light variants. -type OpenCodeTheme struct { - BaseTheme -} - -// NewOpenCodeTheme creates a new instance of the OpenCode theme. -func NewOpenCodeTheme() *OpenCodeTheme { - // OpenCode color palette - // Dark mode colors - darkBackground := "#212121" - darkCurrentLine := "#252525" - darkSelection := "#303030" - darkForeground := "#e0e0e0" - darkComment := "#6a6a6a" - darkPrimary := "#fab283" // Primary orange/gold - darkSecondary := "#5c9cf5" // Secondary blue - darkAccent := "#9d7cd8" // Accent purple - darkRed := "#e06c75" // Error red - darkOrange := "#f5a742" // Warning orange - darkGreen := "#7fd88f" // Success green - darkCyan := "#56b6c2" // Info cyan - darkYellow := "#e5c07b" // Emphasized text - darkBorder := "#4b4c5c" // Border color - - // Light mode colors - lightBackground := "#f8f8f8" - lightCurrentLine := "#f0f0f0" - lightSelection := "#e5e5e6" - lightForeground := "#2a2a2a" - lightComment := "#8a8a8a" - lightPrimary := "#3b7dd8" // Primary blue - lightSecondary := "#7b5bb6" // Secondary purple - lightAccent := "#d68c27" // Accent orange/gold - lightRed := "#d1383d" // Error red - lightOrange := "#d68c27" // Warning orange - lightGreen := "#3d9a57" // Success green - lightCyan := "#318795" // Info cyan - lightYellow := "#b0851f" // Emphasized text - lightBorder := "#d3d3d3" // Border color - - theme := &OpenCodeTheme{} - - // Base colors - theme.PrimaryColor = lipgloss.AdaptiveColor{ - Dark: darkPrimary, - Light: lightPrimary, - } - theme.SecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkSecondary, - Light: lightSecondary, - } - theme.AccentColor = lipgloss.AdaptiveColor{ - Dark: darkAccent, - Light: lightAccent, - } - - // Status colors - theme.ErrorColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.WarningColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SuccessColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.InfoColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - - // Text colors - theme.TextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.TextMutedColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.TextEmphasizedColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - - // Background colors - theme.BackgroundColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkCurrentLine, - Light: lightCurrentLine, - } - theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ - Dark: "#121212", // Slightly darker than background - Light: "#ffffff", // Slightly lighter than background - } - - // Border colors - theme.BorderNormalColor = lipgloss.AdaptiveColor{ - Dark: darkBorder, - Light: lightBorder, - } - theme.BorderFocusedColor = lipgloss.AdaptiveColor{ - Dark: darkPrimary, - Light: lightPrimary, - } - theme.BorderDimColor = lipgloss.AdaptiveColor{ - Dark: darkSelection, - Light: lightSelection, - } - - // Diff view colors - theme.DiffAddedColor = lipgloss.AdaptiveColor{ - Dark: "#478247", - Light: "#2E7D32", - } - theme.DiffRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#7C4444", - Light: "#C62828", - } - theme.DiffContextColor = lipgloss.AdaptiveColor{ - Dark: "#a0a0a0", - Light: "#757575", - } - theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ - Dark: "#a0a0a0", - Light: "#757575", - } - theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ - Dark: "#DAFADA", - Light: "#A5D6A7", - } - theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#FADADD", - Light: "#EF9A9A", - } - theme.DiffAddedBgColor = lipgloss.AdaptiveColor{ - Dark: "#303A30", - Light: "#E8F5E9", - } - theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{ - Dark: "#3A3030", - Light: "#FFEBEE", - } - theme.DiffContextBgColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ - Dark: "#888888", - Light: "#9E9E9E", - } - theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#293229", - Light: "#C8E6C9", - } - theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#332929", - Light: "#FFCDD2", - } - - // Markdown colors - theme.MarkdownTextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ - Dark: darkSecondary, - Light: lightSecondary, - } - theme.MarkdownLinkColor = lipgloss.AdaptiveColor{ - Dark: darkPrimary, - Light: lightPrimary, - } - theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownEmphColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownStrongColor = lipgloss.AdaptiveColor{ - Dark: darkAccent, - Light: lightAccent, - } - theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ - Dark: darkPrimary, - Light: lightPrimary, - } - theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownImageColor = lipgloss.AdaptiveColor{ - Dark: darkPrimary, - Light: lightPrimary, - } - theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - // Syntax highlighting colors - theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ - Dark: darkSecondary, - Light: lightSecondary, - } - theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{ - Dark: darkPrimary, - Light: lightPrimary, - } - theme.SyntaxVariableColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.SyntaxStringColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.SyntaxNumberColor = lipgloss.AdaptiveColor{ - Dark: darkAccent, - Light: lightAccent, - } - theme.SyntaxTypeColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - return theme -} - -func init() { - // Register the OpenCode theme with the theme manager - RegisterTheme("opencode", NewOpenCodeTheme()) -} - diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go deleted file mode 100644 index 4ee14a07f8f2247fd7129bae8fd373d4531adf98..0000000000000000000000000000000000000000 --- a/internal/tui/theme/theme.go +++ /dev/null @@ -1,208 +0,0 @@ -package theme - -import ( - "github.com/charmbracelet/lipgloss" -) - -// Theme defines the interface for all UI themes in the application. -// All colors must be defined as lipgloss.AdaptiveColor to support -// both light and dark terminal backgrounds. -type Theme interface { - // Base colors - Primary() lipgloss.AdaptiveColor - Secondary() lipgloss.AdaptiveColor - Accent() lipgloss.AdaptiveColor - - // Status colors - Error() lipgloss.AdaptiveColor - Warning() lipgloss.AdaptiveColor - Success() lipgloss.AdaptiveColor - Info() lipgloss.AdaptiveColor - - // Text colors - Text() lipgloss.AdaptiveColor - TextMuted() lipgloss.AdaptiveColor - TextEmphasized() lipgloss.AdaptiveColor - - // Background colors - Background() lipgloss.AdaptiveColor - BackgroundSecondary() lipgloss.AdaptiveColor - BackgroundDarker() lipgloss.AdaptiveColor - - // Border colors - BorderNormal() lipgloss.AdaptiveColor - BorderFocused() lipgloss.AdaptiveColor - BorderDim() lipgloss.AdaptiveColor - - // Diff view colors - DiffAdded() lipgloss.AdaptiveColor - DiffRemoved() lipgloss.AdaptiveColor - DiffContext() lipgloss.AdaptiveColor - DiffHunkHeader() lipgloss.AdaptiveColor - DiffHighlightAdded() lipgloss.AdaptiveColor - DiffHighlightRemoved() lipgloss.AdaptiveColor - DiffAddedBg() lipgloss.AdaptiveColor - DiffRemovedBg() lipgloss.AdaptiveColor - DiffContextBg() lipgloss.AdaptiveColor - DiffLineNumber() lipgloss.AdaptiveColor - DiffAddedLineNumberBg() lipgloss.AdaptiveColor - DiffRemovedLineNumberBg() lipgloss.AdaptiveColor - - // Markdown colors - MarkdownText() lipgloss.AdaptiveColor - MarkdownHeading() lipgloss.AdaptiveColor - MarkdownLink() lipgloss.AdaptiveColor - MarkdownLinkText() lipgloss.AdaptiveColor - MarkdownCode() lipgloss.AdaptiveColor - MarkdownBlockQuote() lipgloss.AdaptiveColor - MarkdownEmph() lipgloss.AdaptiveColor - MarkdownStrong() lipgloss.AdaptiveColor - MarkdownHorizontalRule() lipgloss.AdaptiveColor - MarkdownListItem() lipgloss.AdaptiveColor - MarkdownListEnumeration() lipgloss.AdaptiveColor - MarkdownImage() lipgloss.AdaptiveColor - MarkdownImageText() lipgloss.AdaptiveColor - MarkdownCodeBlock() lipgloss.AdaptiveColor - - // Syntax highlighting colors - SyntaxComment() lipgloss.AdaptiveColor - SyntaxKeyword() lipgloss.AdaptiveColor - SyntaxFunction() lipgloss.AdaptiveColor - SyntaxVariable() lipgloss.AdaptiveColor - SyntaxString() lipgloss.AdaptiveColor - SyntaxNumber() lipgloss.AdaptiveColor - SyntaxType() lipgloss.AdaptiveColor - SyntaxOperator() lipgloss.AdaptiveColor - SyntaxPunctuation() lipgloss.AdaptiveColor -} - -// BaseTheme provides a default implementation of the Theme interface -// that can be embedded in concrete theme implementations. -type BaseTheme struct { - // Base colors - PrimaryColor lipgloss.AdaptiveColor - SecondaryColor lipgloss.AdaptiveColor - AccentColor lipgloss.AdaptiveColor - - // Status colors - ErrorColor lipgloss.AdaptiveColor - WarningColor lipgloss.AdaptiveColor - SuccessColor lipgloss.AdaptiveColor - InfoColor lipgloss.AdaptiveColor - - // Text colors - TextColor lipgloss.AdaptiveColor - TextMutedColor lipgloss.AdaptiveColor - TextEmphasizedColor lipgloss.AdaptiveColor - - // Background colors - BackgroundColor lipgloss.AdaptiveColor - BackgroundSecondaryColor lipgloss.AdaptiveColor - BackgroundDarkerColor lipgloss.AdaptiveColor - - // Border colors - BorderNormalColor lipgloss.AdaptiveColor - BorderFocusedColor lipgloss.AdaptiveColor - BorderDimColor lipgloss.AdaptiveColor - - // Diff view colors - DiffAddedColor lipgloss.AdaptiveColor - DiffRemovedColor lipgloss.AdaptiveColor - DiffContextColor lipgloss.AdaptiveColor - DiffHunkHeaderColor lipgloss.AdaptiveColor - DiffHighlightAddedColor lipgloss.AdaptiveColor - DiffHighlightRemovedColor lipgloss.AdaptiveColor - DiffAddedBgColor lipgloss.AdaptiveColor - DiffRemovedBgColor lipgloss.AdaptiveColor - DiffContextBgColor lipgloss.AdaptiveColor - DiffLineNumberColor lipgloss.AdaptiveColor - DiffAddedLineNumberBgColor lipgloss.AdaptiveColor - DiffRemovedLineNumberBgColor lipgloss.AdaptiveColor - - // Markdown colors - MarkdownTextColor lipgloss.AdaptiveColor - MarkdownHeadingColor lipgloss.AdaptiveColor - MarkdownLinkColor lipgloss.AdaptiveColor - MarkdownLinkTextColor lipgloss.AdaptiveColor - MarkdownCodeColor lipgloss.AdaptiveColor - MarkdownBlockQuoteColor lipgloss.AdaptiveColor - MarkdownEmphColor lipgloss.AdaptiveColor - MarkdownStrongColor lipgloss.AdaptiveColor - MarkdownHorizontalRuleColor lipgloss.AdaptiveColor - MarkdownListItemColor lipgloss.AdaptiveColor - MarkdownListEnumerationColor lipgloss.AdaptiveColor - MarkdownImageColor lipgloss.AdaptiveColor - MarkdownImageTextColor lipgloss.AdaptiveColor - MarkdownCodeBlockColor lipgloss.AdaptiveColor - - // Syntax highlighting colors - SyntaxCommentColor lipgloss.AdaptiveColor - SyntaxKeywordColor lipgloss.AdaptiveColor - SyntaxFunctionColor lipgloss.AdaptiveColor - SyntaxVariableColor lipgloss.AdaptiveColor - SyntaxStringColor lipgloss.AdaptiveColor - SyntaxNumberColor lipgloss.AdaptiveColor - SyntaxTypeColor lipgloss.AdaptiveColor - SyntaxOperatorColor lipgloss.AdaptiveColor - SyntaxPunctuationColor lipgloss.AdaptiveColor -} - -// Implement the Theme interface for BaseTheme -func (t *BaseTheme) Primary() lipgloss.AdaptiveColor { return t.PrimaryColor } -func (t *BaseTheme) Secondary() lipgloss.AdaptiveColor { return t.SecondaryColor } -func (t *BaseTheme) Accent() lipgloss.AdaptiveColor { return t.AccentColor } - -func (t *BaseTheme) Error() lipgloss.AdaptiveColor { return t.ErrorColor } -func (t *BaseTheme) Warning() lipgloss.AdaptiveColor { return t.WarningColor } -func (t *BaseTheme) Success() lipgloss.AdaptiveColor { return t.SuccessColor } -func (t *BaseTheme) Info() lipgloss.AdaptiveColor { return t.InfoColor } - -func (t *BaseTheme) Text() lipgloss.AdaptiveColor { return t.TextColor } -func (t *BaseTheme) TextMuted() lipgloss.AdaptiveColor { return t.TextMutedColor } -func (t *BaseTheme) TextEmphasized() lipgloss.AdaptiveColor { return t.TextEmphasizedColor } - -func (t *BaseTheme) Background() lipgloss.AdaptiveColor { return t.BackgroundColor } -func (t *BaseTheme) BackgroundSecondary() lipgloss.AdaptiveColor { return t.BackgroundSecondaryColor } -func (t *BaseTheme) BackgroundDarker() lipgloss.AdaptiveColor { return t.BackgroundDarkerColor } - -func (t *BaseTheme) BorderNormal() lipgloss.AdaptiveColor { return t.BorderNormalColor } -func (t *BaseTheme) BorderFocused() lipgloss.AdaptiveColor { return t.BorderFocusedColor } -func (t *BaseTheme) BorderDim() lipgloss.AdaptiveColor { return t.BorderDimColor } - -func (t *BaseTheme) DiffAdded() lipgloss.AdaptiveColor { return t.DiffAddedColor } -func (t *BaseTheme) DiffRemoved() lipgloss.AdaptiveColor { return t.DiffRemovedColor } -func (t *BaseTheme) DiffContext() lipgloss.AdaptiveColor { return t.DiffContextColor } -func (t *BaseTheme) DiffHunkHeader() lipgloss.AdaptiveColor { return t.DiffHunkHeaderColor } -func (t *BaseTheme) DiffHighlightAdded() lipgloss.AdaptiveColor { return t.DiffHighlightAddedColor } -func (t *BaseTheme) DiffHighlightRemoved() lipgloss.AdaptiveColor { return t.DiffHighlightRemovedColor } -func (t *BaseTheme) DiffAddedBg() lipgloss.AdaptiveColor { return t.DiffAddedBgColor } -func (t *BaseTheme) DiffRemovedBg() lipgloss.AdaptiveColor { return t.DiffRemovedBgColor } -func (t *BaseTheme) DiffContextBg() lipgloss.AdaptiveColor { return t.DiffContextBgColor } -func (t *BaseTheme) DiffLineNumber() lipgloss.AdaptiveColor { return t.DiffLineNumberColor } -func (t *BaseTheme) DiffAddedLineNumberBg() lipgloss.AdaptiveColor { return t.DiffAddedLineNumberBgColor } -func (t *BaseTheme) DiffRemovedLineNumberBg() lipgloss.AdaptiveColor { return t.DiffRemovedLineNumberBgColor } - -func (t *BaseTheme) MarkdownText() lipgloss.AdaptiveColor { return t.MarkdownTextColor } -func (t *BaseTheme) MarkdownHeading() lipgloss.AdaptiveColor { return t.MarkdownHeadingColor } -func (t *BaseTheme) MarkdownLink() lipgloss.AdaptiveColor { return t.MarkdownLinkColor } -func (t *BaseTheme) MarkdownLinkText() lipgloss.AdaptiveColor { return t.MarkdownLinkTextColor } -func (t *BaseTheme) MarkdownCode() lipgloss.AdaptiveColor { return t.MarkdownCodeColor } -func (t *BaseTheme) MarkdownBlockQuote() lipgloss.AdaptiveColor { return t.MarkdownBlockQuoteColor } -func (t *BaseTheme) MarkdownEmph() lipgloss.AdaptiveColor { return t.MarkdownEmphColor } -func (t *BaseTheme) MarkdownStrong() lipgloss.AdaptiveColor { return t.MarkdownStrongColor } -func (t *BaseTheme) MarkdownHorizontalRule() lipgloss.AdaptiveColor { return t.MarkdownHorizontalRuleColor } -func (t *BaseTheme) MarkdownListItem() lipgloss.AdaptiveColor { return t.MarkdownListItemColor } -func (t *BaseTheme) MarkdownListEnumeration() lipgloss.AdaptiveColor { return t.MarkdownListEnumerationColor } -func (t *BaseTheme) MarkdownImage() lipgloss.AdaptiveColor { return t.MarkdownImageColor } -func (t *BaseTheme) MarkdownImageText() lipgloss.AdaptiveColor { return t.MarkdownImageTextColor } -func (t *BaseTheme) MarkdownCodeBlock() lipgloss.AdaptiveColor { return t.MarkdownCodeBlockColor } - -func (t *BaseTheme) SyntaxComment() lipgloss.AdaptiveColor { return t.SyntaxCommentColor } -func (t *BaseTheme) SyntaxKeyword() lipgloss.AdaptiveColor { return t.SyntaxKeywordColor } -func (t *BaseTheme) SyntaxFunction() lipgloss.AdaptiveColor { return t.SyntaxFunctionColor } -func (t *BaseTheme) SyntaxVariable() lipgloss.AdaptiveColor { return t.SyntaxVariableColor } -func (t *BaseTheme) SyntaxString() lipgloss.AdaptiveColor { return t.SyntaxStringColor } -func (t *BaseTheme) SyntaxNumber() lipgloss.AdaptiveColor { return t.SyntaxNumberColor } -func (t *BaseTheme) SyntaxType() lipgloss.AdaptiveColor { return t.SyntaxTypeColor } -func (t *BaseTheme) SyntaxOperator() lipgloss.AdaptiveColor { return t.SyntaxOperatorColor } -func (t *BaseTheme) SyntaxPunctuation() lipgloss.AdaptiveColor { return t.SyntaxPunctuationColor } \ No newline at end of file diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go deleted file mode 100644 index 5ec810e3377ebfeb1a1ef6d0b399e6baefd0e403..0000000000000000000000000000000000000000 --- a/internal/tui/theme/theme_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package theme - -import ( - "testing" -) - -func TestThemeRegistration(t *testing.T) { - // Get list of available themes - availableThemes := AvailableThemes() - - // Check if "catppuccin" theme is registered - catppuccinFound := false - for _, themeName := range availableThemes { - if themeName == "catppuccin" { - catppuccinFound = true - break - } - } - - if !catppuccinFound { - t.Errorf("Catppuccin theme is not registered") - } - - // Check if "gruvbox" theme is registered - gruvboxFound := false - for _, themeName := range availableThemes { - if themeName == "gruvbox" { - gruvboxFound = true - break - } - } - - if !gruvboxFound { - t.Errorf("Gruvbox theme is not registered") - } - - // Check if "monokai" theme is registered - monokaiFound := false - for _, themeName := range availableThemes { - if themeName == "monokai" { - monokaiFound = true - break - } - } - - if !monokaiFound { - t.Errorf("Monokai theme is not registered") - } - - // Try to get the themes and make sure they're not nil - catppuccin := GetTheme("catppuccin") - if catppuccin == nil { - t.Errorf("Catppuccin theme is nil") - } - - gruvbox := GetTheme("gruvbox") - if gruvbox == nil { - t.Errorf("Gruvbox theme is nil") - } - - monokai := GetTheme("monokai") - if monokai == nil { - t.Errorf("Monokai theme is nil") - } - - // Test switching theme - originalTheme := CurrentThemeName() - - err := SetTheme("gruvbox") - if err != nil { - t.Errorf("Failed to set theme to gruvbox: %v", err) - } - - if CurrentThemeName() != "gruvbox" { - t.Errorf("Theme not properly switched to gruvbox") - } - - err = SetTheme("monokai") - if err != nil { - t.Errorf("Failed to set theme to monokai: %v", err) - } - - if CurrentThemeName() != "monokai" { - t.Errorf("Theme not properly switched to monokai") - } - - // Switch back to original theme - _ = SetTheme(originalTheme) -} \ No newline at end of file diff --git a/internal/tui/theme/tokyonight.go b/internal/tui/theme/tokyonight.go deleted file mode 100644 index acd9dbf6c0311c9226d1c81e919df30364272b62..0000000000000000000000000000000000000000 --- a/internal/tui/theme/tokyonight.go +++ /dev/null @@ -1,274 +0,0 @@ -package theme - -import ( - "github.com/charmbracelet/lipgloss" -) - -// TokyoNightTheme implements the Theme interface with Tokyo Night colors. -// It provides both dark and light variants. -type TokyoNightTheme struct { - BaseTheme -} - -// NewTokyoNightTheme creates a new instance of the Tokyo Night theme. -func NewTokyoNightTheme() *TokyoNightTheme { - // Tokyo Night color palette - // Dark mode colors - darkBackground := "#222436" - darkCurrentLine := "#1e2030" - darkSelection := "#2f334d" - darkForeground := "#c8d3f5" - darkComment := "#636da6" - darkRed := "#ff757f" - darkOrange := "#ff966c" - darkYellow := "#ffc777" - darkGreen := "#c3e88d" - darkCyan := "#86e1fc" - darkBlue := "#82aaff" - darkPurple := "#c099ff" - darkBorder := "#3b4261" - - // Light mode colors (Tokyo Night Day) - lightBackground := "#e1e2e7" - lightCurrentLine := "#d5d6db" - lightSelection := "#c8c9ce" - lightForeground := "#3760bf" - lightComment := "#848cb5" - lightRed := "#f52a65" - lightOrange := "#b15c00" - lightYellow := "#8c6c3e" - lightGreen := "#587539" - lightCyan := "#007197" - lightBlue := "#2e7de9" - lightPurple := "#9854f1" - lightBorder := "#a8aecb" - - theme := &TokyoNightTheme{} - - // Base colors - theme.PrimaryColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.SecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.AccentColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - - // Status colors - theme.ErrorColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.WarningColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SuccessColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.InfoColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - - // Text colors - theme.TextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.TextMutedColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.TextEmphasizedColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - - // Background colors - theme.BackgroundColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkCurrentLine, - Light: lightCurrentLine, - } - theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ - Dark: "#191B29", // Darker background from palette - Light: "#f0f0f5", // Slightly lighter than background - } - - // Border colors - theme.BorderNormalColor = lipgloss.AdaptiveColor{ - Dark: darkBorder, - Light: lightBorder, - } - theme.BorderFocusedColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.BorderDimColor = lipgloss.AdaptiveColor{ - Dark: darkSelection, - Light: lightSelection, - } - - // Diff view colors - theme.DiffAddedColor = lipgloss.AdaptiveColor{ - Dark: "#4fd6be", // teal from palette - Light: "#1e725c", - } - theme.DiffRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#c53b53", // red1 from palette - Light: "#c53b53", - } - theme.DiffContextColor = lipgloss.AdaptiveColor{ - Dark: "#828bb8", // fg_dark from palette - Light: "#7086b5", - } - theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ - Dark: "#828bb8", // fg_dark from palette - Light: "#7086b5", - } - theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ - Dark: "#b8db87", // git.add from palette - Light: "#4db380", - } - theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#e26a75", // git.delete from palette - Light: "#f52a65", - } - theme.DiffAddedBgColor = lipgloss.AdaptiveColor{ - Dark: "#20303b", - Light: "#d5e5d5", - } - theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{ - Dark: "#37222c", - Light: "#f7d8db", - } - theme.DiffContextBgColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ - Dark: "#545c7e", // dark3 from palette - Light: "#848cb5", - } - theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#1b2b34", - Light: "#c5d5c5", - } - theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#2d1f26", - Light: "#e7c8cb", - } - - // Markdown colors - theme.MarkdownTextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.MarkdownLinkColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownEmphColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownStrongColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownImageColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - // Syntax highlighting colors - theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.SyntaxVariableColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.SyntaxStringColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.SyntaxNumberColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SyntaxTypeColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - return theme -} - -func init() { - // Register the Tokyo Night theme with the theme manager - RegisterTheme("tokyonight", NewTokyoNightTheme()) -} \ No newline at end of file diff --git a/internal/tui/theme/tron.go b/internal/tui/theme/tron.go deleted file mode 100644 index 5f1bdfb0d5aa1594c82bfb0e22f506a9b53e171a..0000000000000000000000000000000000000000 --- a/internal/tui/theme/tron.go +++ /dev/null @@ -1,276 +0,0 @@ -package theme - -import ( - "github.com/charmbracelet/lipgloss" -) - -// TronTheme implements the Theme interface with Tron-inspired colors. -// It provides both dark and light variants, though Tron is primarily a dark theme. -type TronTheme struct { - BaseTheme -} - -// NewTronTheme creates a new instance of the Tron theme. -func NewTronTheme() *TronTheme { - // Tron color palette - // Inspired by the Tron movie's neon aesthetic - darkBackground := "#0c141f" - darkCurrentLine := "#1a2633" - darkSelection := "#1a2633" - darkForeground := "#caf0ff" - darkComment := "#4d6b87" - darkCyan := "#00d9ff" - darkBlue := "#007fff" - darkOrange := "#ff9000" - darkPink := "#ff00a0" - darkPurple := "#b73fff" - darkRed := "#ff3333" - darkYellow := "#ffcc00" - darkGreen := "#00ff8f" - darkBorder := "#1a2633" - - // Light mode approximation - lightBackground := "#f0f8ff" - lightCurrentLine := "#e0f0ff" - lightSelection := "#d0e8ff" - lightForeground := "#0c141f" - lightComment := "#4d6b87" - lightCyan := "#0097b3" - lightBlue := "#0066cc" - lightOrange := "#cc7300" - lightPink := "#cc0080" - lightPurple := "#9932cc" - lightRed := "#cc2929" - lightYellow := "#cc9900" - lightGreen := "#00cc72" - lightBorder := "#d0e8ff" - - theme := &TronTheme{} - - // Base colors - theme.PrimaryColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.SecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.AccentColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - - // Status colors - theme.ErrorColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.WarningColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SuccessColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.InfoColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - - // Text colors - theme.TextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.TextMutedColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.TextEmphasizedColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - - // Background colors - theme.BackgroundColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{ - Dark: darkCurrentLine, - Light: lightCurrentLine, - } - theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{ - Dark: "#070d14", // Slightly darker than background - Light: "#ffffff", // Slightly lighter than background - } - - // Border colors - theme.BorderNormalColor = lipgloss.AdaptiveColor{ - Dark: darkBorder, - Light: lightBorder, - } - theme.BorderFocusedColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.BorderDimColor = lipgloss.AdaptiveColor{ - Dark: darkSelection, - Light: lightSelection, - } - - // Diff view colors - theme.DiffAddedColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.DiffRemovedColor = lipgloss.AdaptiveColor{ - Dark: darkRed, - Light: lightRed, - } - theme.DiffContextColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{ - Dark: "#00ff8f", - Light: "#a5d6a7", - } - theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{ - Dark: "#ff3333", - Light: "#ef9a9a", - } - theme.DiffAddedBgColor = lipgloss.AdaptiveColor{ - Dark: "#0a2a1a", - Light: "#e8f5e9", - } - theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{ - Dark: "#2a0a0a", - Light: "#ffebee", - } - theme.DiffContextBgColor = lipgloss.AdaptiveColor{ - Dark: darkBackground, - Light: lightBackground, - } - theme.DiffLineNumberColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#082015", - Light: "#c8e6c9", - } - theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{ - Dark: "#200808", - Light: "#ffcdd2", - } - - // Markdown colors - theme.MarkdownTextColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownLinkColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownEmphColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.MarkdownStrongColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.MarkdownListItemColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownImageColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - // Syntax highlighting colors - theme.SyntaxCommentColor = lipgloss.AdaptiveColor{ - Dark: darkComment, - Light: lightComment, - } - theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{ - Dark: darkCyan, - Light: lightCyan, - } - theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{ - Dark: darkGreen, - Light: lightGreen, - } - theme.SyntaxVariableColor = lipgloss.AdaptiveColor{ - Dark: darkOrange, - Light: lightOrange, - } - theme.SyntaxStringColor = lipgloss.AdaptiveColor{ - Dark: darkYellow, - Light: lightYellow, - } - theme.SyntaxNumberColor = lipgloss.AdaptiveColor{ - Dark: darkBlue, - Light: lightBlue, - } - theme.SyntaxTypeColor = lipgloss.AdaptiveColor{ - Dark: darkPurple, - Light: lightPurple, - } - theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{ - Dark: darkPink, - Light: lightPink, - } - theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{ - Dark: darkForeground, - Light: lightForeground, - } - - return theme -} - -func init() { - // Register the Tron theme with the theme manager - RegisterTheme("tron", NewTronTheme()) -} \ No newline at end of file diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 060b8c79c8572a0508ebcd95a148ff0743bc7009..e2c037a586aaad777412372b79b5596f22d569f4 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -2,167 +2,67 @@ package tui import ( "context" - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/agent" - "github.com/opencode-ai/opencode/internal/logging" - "github.com/opencode-ai/opencode/internal/permission" - "github.com/opencode-ai/opencode/internal/pubsub" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/components/chat" - "github.com/opencode-ai/opencode/internal/tui/components/core" - "github.com/opencode-ai/opencode/internal/tui/components/dialog" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/page" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -type keyMap struct { - Logs key.Binding - Quit key.Binding - Help key.Binding - SwitchSession key.Binding - Commands key.Binding - Filepicker key.Binding - Models key.Binding - SwitchTheme key.Binding -} - -type startCompactSessionMsg struct{} -const ( - quitKey = "q" -) - -var keys = keyMap{ - Logs: key.NewBinding( - key.WithKeys("ctrl+l"), - key.WithHelp("ctrl+l", "logs"), - ), - - Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - Help: key.NewBinding( - key.WithKeys("ctrl+_"), - key.WithHelp("ctrl+?", "toggle help"), - ), - - SwitchSession: key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "switch session"), - ), - - Commands: key.NewBinding( - key.WithKeys("ctrl+k"), - key.WithHelp("ctrl+k", "commands"), - ), - Filepicker: key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "select files to upload"), - ), - Models: key.NewBinding( - key.WithKeys("ctrl+o"), - key.WithHelp("ctrl+o", "model selection"), - ), - - SwitchTheme: key.NewBinding( - key.WithKeys("ctrl+t"), - key.WithHelp("ctrl+t", "switch theme"), - ), -} - -var helpEsc = key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), -) - -var returnKey = key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), -) - -var logsKeyReturnKey = key.NewBinding( - key.WithKeys("esc", "backspace", quitKey), - key.WithHelp("esc/q", "go back"), + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/llm/agent" + "github.com/charmbracelet/crush/internal/llm/tools" + "github.com/charmbracelet/crush/internal/logging" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" + cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core/status" + "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" + initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" + "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions" + "github.com/charmbracelet/crush/internal/tui/layout" + "github.com/charmbracelet/crush/internal/tui/page" + "github.com/charmbracelet/crush/internal/tui/page/chat" + "github.com/charmbracelet/crush/internal/tui/page/logs" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/lipgloss/v2" ) +// appModel represents the main application model that manages pages, dialogs, and UI state. type appModel struct { - width, height int - currentPage page.PageID - previousPage page.PageID - pages map[page.PageID]tea.Model - loadedPages map[page.PageID]bool - status core.StatusCmp - app *app.App - selectedSession session.Session - - showPermissions bool - permissions dialog.PermissionDialogCmp + width, height int + keyMap KeyMap - showHelp bool - help dialog.HelpCmp + currentPage page.PageID + previousPage page.PageID + pages map[page.PageID]util.Model + loadedPages map[page.PageID]bool - showQuit bool - quit dialog.QuitDialog + status status.StatusCmp - showSessionDialog bool - sessionDialog dialog.SessionDialog + app *app.App - showCommandDialog bool - commandDialog dialog.CommandDialog - commands []dialog.Command + dialog dialogs.DialogCmp + completions completions.Completions - showModelDialog bool - modelDialog dialog.ModelDialog - - showInitDialog bool - initDialog dialog.InitDialogCmp - - showFilepicker bool - filepicker dialog.FilepickerCmp - - showThemeDialog bool - themeDialog dialog.ThemeDialog - - showMultiArgumentsDialog bool - multiArgumentsDialog dialog.MultiArgumentsDialogCmp - - isCompacting bool - compactingMessage string + // Session + selectedSessionID string // The ID of the currently selected session } +// Init initializes the application model and returns initial commands. func (a appModel) Init() tea.Cmd { var cmds []tea.Cmd cmd := a.pages[a.currentPage].Init() - a.loadedPages[a.currentPage] = true cmds = append(cmds, cmd) + a.loadedPages[a.currentPage] = true + cmd = a.status.Init() cmds = append(cmds, cmd) - cmd = a.quit.Init() - cmds = append(cmds, cmd) - cmd = a.help.Init() - cmds = append(cmds, cmd) - cmd = a.sessionDialog.Init() - cmds = append(cmds, cmd) - cmd = a.commandDialog.Init() - cmds = append(cmds, cmd) - cmd = a.modelDialog.Init() - cmds = append(cmds, cmd) - cmd = a.initDialog.Init() - cmds = append(cmds, cmd) - cmd = a.filepicker.Init() - cmds = append(cmds, cmd) - cmd = a.themeDialog.Init() - cmds = append(cmds, cmd) // Check if we should show the init dialog cmds = append(cmds, func() tea.Msg { @@ -173,510 +73,278 @@ func (a appModel) Init() tea.Cmd { Msg: "Failed to check init status: " + err.Error(), } } - return dialog.ShowInitDialogMsg{Show: shouldShow} + if shouldShow { + return dialogs.OpenDialogMsg{ + Model: initDialog.NewInitDialogCmp(), + } + } + return nil }) return tea.Batch(cmds...) } -func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// Update handles incoming messages and updates the application state. +func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd + switch msg := msg.(type) { case tea.WindowSizeMsg: - msg.Height -= 1 // Make space for the status bar - a.width, a.height = msg.Width, msg.Height - - s, _ := a.status.Update(msg) - a.status = s.(core.StatusCmp) - a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) - cmds = append(cmds, cmd) - - prm, permCmd := a.permissions.Update(msg) - a.permissions = prm.(dialog.PermissionDialogCmp) - cmds = append(cmds, permCmd) - - help, helpCmd := a.help.Update(msg) - a.help = help.(dialog.HelpCmp) - cmds = append(cmds, helpCmd) - - session, sessionCmd := a.sessionDialog.Update(msg) - a.sessionDialog = session.(dialog.SessionDialog) - cmds = append(cmds, sessionCmd) - - command, commandCmd := a.commandDialog.Update(msg) - a.commandDialog = command.(dialog.CommandDialog) - cmds = append(cmds, commandCmd) - - filepicker, filepickerCmd := a.filepicker.Update(msg) - a.filepicker = filepicker.(dialog.FilepickerCmp) - cmds = append(cmds, filepickerCmd) + return a, a.handleWindowResize(msg) + + // Completions messages + case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg: + u, completionCmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return a, completionCmd + + // Dialog messages + case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg: + u, dialogCmd := a.dialog.Update(msg) + a.dialog = u.(dialogs.DialogCmp) + return a, dialogCmd + case commands.ShowArgumentsDialogMsg: + return a, util.CmdHandler( + dialogs.OpenDialogMsg{ + Model: commands.NewCommandArgumentsDialog( + msg.CommandID, + msg.Content, + msg.ArgNames, + ), + }, + ) + // Page change messages + case page.PageChangeMsg: + return a, a.moveToPage(msg.ID) - a.initDialog.SetSize(msg.Width, msg.Height) + // Status Messages + case util.InfoMsg, util.ClearStatusMsg: + s, statusCmd := a.status.Update(msg) + a.status = s.(status.StatusCmp) + cmds = append(cmds, statusCmd) + return a, tea.Batch(cmds...) - if a.showMultiArgumentsDialog { - a.multiArgumentsDialog.SetSize(msg.Width, msg.Height) - args, argsCmd := a.multiArgumentsDialog.Update(msg) - a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp) - cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init()) + // Session + case cmpChat.SessionSelectedMsg: + a.selectedSessionID = msg.ID + case cmpChat.SessionClearedMsg: + a.selectedSessionID = "" + // Logs + case pubsub.Event[logging.LogMessage]: + // Send to the status component + s, statusCmd := a.status.Update(msg) + a.status = s.(status.StatusCmp) + cmds = append(cmds, statusCmd) + + // If the current page is logs, update the logs view + if a.currentPage == logs.LogsPage { + updated, pageCmd := a.pages[a.currentPage].Update(msg) + a.pages[a.currentPage] = updated.(util.Model) + cmds = append(cmds, pageCmd) } - - return a, tea.Batch(cmds...) - // Status - case util.InfoMsg: - s, cmd := a.status.Update(msg) - a.status = s.(core.StatusCmp) - cmds = append(cmds, cmd) return a, tea.Batch(cmds...) - case pubsub.Event[logging.LogMessage]: - if msg.Payload.Persist { - switch msg.Payload.Level { - case "error": - s, cmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeError, - Msg: msg.Payload.Message, - TTL: msg.Payload.PersistTime, - }) - a.status = s.(core.StatusCmp) - cmds = append(cmds, cmd) - case "info": - s, cmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeInfo, - Msg: msg.Payload.Message, - TTL: msg.Payload.PersistTime, - }) - a.status = s.(core.StatusCmp) - cmds = append(cmds, cmd) - - case "warn": - s, cmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeWarn, - Msg: msg.Payload.Message, - TTL: msg.Payload.PersistTime, - }) - - a.status = s.(core.StatusCmp) - cmds = append(cmds, cmd) - default: - s, cmd := a.status.Update(util.InfoMsg{ - Type: util.InfoTypeInfo, - Msg: msg.Payload.Message, - TTL: msg.Payload.PersistTime, - }) - a.status = s.(core.StatusCmp) - cmds = append(cmds, cmd) + // Commands + case commands.SwitchSessionsMsg: + return a, func() tea.Msg { + allSessions, _ := a.app.Sessions.List(context.Background()) + return dialogs.OpenDialogMsg{ + Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID), } } - case util.ClearStatusMsg: - s, _ := a.status.Update(msg) - a.status = s.(core.StatusCmp) - // Permission + case commands.SwitchModelMsg: + return a, util.CmdHandler( + dialogs.OpenDialogMsg{ + Model: models.NewModelDialogCmp(), + }, + ) + // Compact + case commands.CompactMsg: + return a, util.CmdHandler(dialogs.OpenDialogMsg{ + Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true), + }) + + // File Picker + case chat.OpenFilePickerMsg: + if a.dialog.ActiveDialogId() == filepicker.FilePickerID { + // If the commands dialog is already open, close it + return a, util.CmdHandler(dialogs.CloseDialogMsg{}) + } + return a, util.CmdHandler(dialogs.OpenDialogMsg{ + Model: filepicker.NewFilePickerCmp(), + }) + // Permissions case pubsub.Event[permission.PermissionRequest]: - a.showPermissions = true - return a, a.permissions.SetPermissions(msg.Payload) - case dialog.PermissionResponseMsg: - var cmd tea.Cmd + return a, util.CmdHandler(dialogs.OpenDialogMsg{ + Model: permissions.NewPermissionDialogCmp(msg.Payload), + }) + case permissions.PermissionResponseMsg: switch msg.Action { - case dialog.PermissionAllow: + case permissions.PermissionAllow: a.app.Permissions.Grant(msg.Permission) - case dialog.PermissionAllowForSession: - a.app.Permissions.GrantPersistant(msg.Permission) - case dialog.PermissionDeny: + case permissions.PermissionAllowForSession: + a.app.Permissions.GrantPersistent(msg.Permission) + case permissions.PermissionDeny: a.app.Permissions.Deny(msg.Permission) } - a.showPermissions = false - return a, cmd - - case page.PageChangeMsg: - return a, a.moveToPage(msg.ID) - - case dialog.CloseQuitMsg: - a.showQuit = false - return a, nil - - case dialog.CloseSessionDialogMsg: - a.showSessionDialog = false return a, nil - - case dialog.CloseCommandDialogMsg: - a.showCommandDialog = false - return a, nil - - case startCompactSessionMsg: - // Start compacting the current session - a.isCompacting = true - a.compactingMessage = "Starting summarization..." - - if a.selectedSession.ID == "" { - a.isCompacting = false - return a, util.ReportWarn("No active session to summarize") - } - - // Start the summarization process - return a, func() tea.Msg { - ctx := context.Background() - a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID) - return nil - } - + // Agent Events case pubsub.Event[agent.AgentEvent]: payload := msg.Payload - if payload.Error != nil { - a.isCompacting = false - return a, util.ReportError(payload.Error) - } - - a.compactingMessage = payload.Progress - - if payload.Done && payload.Type == agent.AgentEventTypeSummarize { - a.isCompacting = false - return a, util.ReportInfo("Session summarization complete") - } else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" { - model := a.app.CoderAgent.Model() - contextWindow := model.ContextWindow - tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens - if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact { - return a, util.CmdHandler(startCompactSessionMsg{}) - } - } - // Continue listening for events - return a, nil - - case dialog.CloseThemeDialogMsg: - a.showThemeDialog = false - return a, nil - - case dialog.ThemeChangedMsg: - a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) - a.showThemeDialog = false - return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName)) - - case dialog.CloseModelDialogMsg: - a.showModelDialog = false - return a, nil - - case dialog.ModelSelectedMsg: - a.showModelDialog = false - model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID) - if err != nil { - return a, util.ReportError(err) + // Forward agent events to dialogs + if a.dialog.HasDialogs() && a.dialog.ActiveDialogId() == compact.CompactDialogID { + u, dialogCmd := a.dialog.Update(payload) + a.dialog = u.(dialogs.DialogCmp) + cmds = append(cmds, dialogCmd) } - return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name)) - - case dialog.ShowInitDialogMsg: - a.showInitDialog = msg.Show - return a, nil - - case dialog.CloseInitDialogMsg: - a.showInitDialog = false - if msg.Initialize { - // Run the initialization command - for _, cmd := range a.commands { - if cmd.ID == "init" { - // Mark the project as initialized - if err := config.MarkProjectInitialized(); err != nil { - return a, util.ReportError(err) - } - return a, cmd.Handler(cmd) + // Handle auto-compact logic + if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" { + // Get current session to check token usage + session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID) + if err == nil { + model := a.app.CoderAgent.Model() + contextWindow := model.ContextWindow + tokens := session.CompletionTokens + session.PromptTokens + if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact { + // Show compact confirmation dialog + cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{ + Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false), + })) } } - } else { - // Mark the project as initialized without running the command - if err := config.MarkProjectInitialized(); err != nil { - return a, util.ReportError(err) - } } - return a, nil - - case chat.SessionSelectedMsg: - a.selectedSession = msg - a.sessionDialog.SetSelectedSession(msg.ID) - case pubsub.Event[session.Session]: - if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID { - a.selectedSession = msg.Payload - } - case dialog.SessionSelectedMsg: - a.showSessionDialog = false - if a.currentPage == page.ChatPage { - return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session)) - } - return a, nil - - case dialog.CommandSelectedMsg: - a.showCommandDialog = false - // Execute the command handler if available - if msg.Command.Handler != nil { - return a, msg.Command.Handler(msg.Command) - } - return a, util.ReportInfo("Command selected: " + msg.Command.Title) - - case dialog.ShowMultiArgumentsDialogMsg: - // Show multi-arguments dialog - a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames) - a.showMultiArgumentsDialog = true - return a, a.multiArgumentsDialog.Init() - - case dialog.CloseMultiArgumentsDialogMsg: - // Close multi-arguments dialog - a.showMultiArgumentsDialog = false - - // If submitted, replace all named arguments and run the command - if msg.Submit { - content := msg.Content - - // Replace each named argument with its value - for name, value := range msg.Args { - placeholder := "$" + name - content = strings.ReplaceAll(content, placeholder, value) - } - - // Execute the command with arguments - return a, util.CmdHandler(dialog.CommandRunCustomMsg{ - Content: content, - Args: msg.Args, + return a, tea.Batch(cmds...) + // Key Press Messages + case tea.KeyPressMsg: + if msg.String() == "ctrl+t" { + go a.app.Permissions.Request(permission.CreatePermissionRequest{ + SessionID: "123", + ToolName: "bash", + Action: "execute", + Params: tools.BashPermissionsParams{ + Command: "ls -la", + }, }) } - return a, nil - - case tea.KeyMsg: - // If multi-arguments dialog is open, let it handle the key press first - if a.showMultiArgumentsDialog { - args, cmd := a.multiArgumentsDialog.Update(msg) - a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp) - return a, cmd - } - - switch { + return a, a.handleKeyPressMsg(msg) + } + s, _ := a.status.Update(msg) + a.status = s.(status.StatusCmp) + updated, cmd := a.pages[a.currentPage].Update(msg) + a.pages[a.currentPage] = updated.(util.Model) + if a.dialog.HasDialogs() { + u, dialogCmd := a.dialog.Update(msg) + a.dialog = u.(dialogs.DialogCmp) + cmds = append(cmds, dialogCmd) + } + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) +} - case key.Matches(msg, keys.Quit): - a.showQuit = !a.showQuit - if a.showHelp { - a.showHelp = false - } - if a.showSessionDialog { - a.showSessionDialog = false - } - if a.showCommandDialog { - a.showCommandDialog = false - } - if a.showFilepicker { - a.showFilepicker = false - a.filepicker.ToggleFilepicker(a.showFilepicker) - } - if a.showModelDialog { - a.showModelDialog = false - } - if a.showMultiArgumentsDialog { - a.showMultiArgumentsDialog = false - } - return a, nil - case key.Matches(msg, keys.SwitchSession): - if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog { - // Load sessions and show the dialog - sessions, err := a.app.Sessions.List(context.Background()) - if err != nil { - return a, util.ReportError(err) - } - if len(sessions) == 0 { - return a, util.ReportWarn("No sessions available") - } - a.sessionDialog.SetSessions(sessions) - a.showSessionDialog = true - return a, nil - } - return a, nil - case key.Matches(msg, keys.Commands): - if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker { - // Show commands dialog - if len(a.commands) == 0 { - return a, util.ReportWarn("No commands available") - } - a.commandDialog.SetCommands(a.commands) - a.showCommandDialog = true - return a, nil - } - return a, nil - case key.Matches(msg, keys.Models): - if a.showModelDialog { - a.showModelDialog = false - return a, nil - } - if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { - a.showModelDialog = true - return a, nil - } - return a, nil - case key.Matches(msg, keys.SwitchTheme): - if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { - // Show theme switcher dialog - a.showThemeDialog = true - // Theme list is dynamically loaded by the dialog component - return a, a.themeDialog.Init() - } - return a, nil - case key.Matches(msg, returnKey) || key.Matches(msg): - if msg.String() == quitKey { - if a.currentPage == page.LogsPage { - return a, a.moveToPage(page.ChatPage) - } - } else if !a.filepicker.IsCWDFocused() { - if a.showQuit { - a.showQuit = !a.showQuit - return a, nil - } - if a.showHelp { - a.showHelp = !a.showHelp - return a, nil - } - if a.showInitDialog { - a.showInitDialog = false - // Mark the project as initialized without running the command - if err := config.MarkProjectInitialized(); err != nil { - return a, util.ReportError(err) - } - return a, nil - } - if a.showFilepicker { - a.showFilepicker = false - a.filepicker.ToggleFilepicker(a.showFilepicker) - return a, nil - } - if a.currentPage == page.LogsPage { - return a, a.moveToPage(page.ChatPage) - } - } - case key.Matches(msg, keys.Logs): - return a, a.moveToPage(page.LogsPage) - case key.Matches(msg, keys.Help): - if a.showQuit { - return a, nil - } - a.showHelp = !a.showHelp - return a, nil - case key.Matches(msg, helpEsc): - if a.app.CoderAgent.IsBusy() { - if a.showQuit { - return a, nil - } - a.showHelp = !a.showHelp - return a, nil - } - case key.Matches(msg, keys.Filepicker): - a.showFilepicker = !a.showFilepicker - a.filepicker.ToggleFilepicker(a.showFilepicker) - return a, nil - } - default: - f, filepickerCmd := a.filepicker.Update(msg) - a.filepicker = f.(dialog.FilepickerCmp) - cmds = append(cmds, filepickerCmd) +// handleWindowResize processes window resize events and updates all components. +func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd { + var cmds []tea.Cmd + msg.Height -= 2 // Make space for the status bar + a.width, a.height = msg.Width, msg.Height - } + // Update status bar + s, cmd := a.status.Update(msg) + a.status = s.(status.StatusCmp) + cmds = append(cmds, cmd) - if a.showFilepicker { - f, filepickerCmd := a.filepicker.Update(msg) - a.filepicker = f.(dialog.FilepickerCmp) - cmds = append(cmds, filepickerCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyMsg); ok { - return a, tea.Batch(cmds...) - } - } + // Update the current page + updated, cmd := a.pages[a.currentPage].Update(msg) + a.pages[a.currentPage] = updated.(util.Model) + cmds = append(cmds, cmd) - if a.showQuit { - q, quitCmd := a.quit.Update(msg) - a.quit = q.(dialog.QuitDialog) - cmds = append(cmds, quitCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyMsg); ok { - return a, tea.Batch(cmds...) - } - } - if a.showPermissions { - d, permissionsCmd := a.permissions.Update(msg) - a.permissions = d.(dialog.PermissionDialogCmp) - cmds = append(cmds, permissionsCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyMsg); ok { - return a, tea.Batch(cmds...) - } - } + // Update the dialogs + dialog, cmd := a.dialog.Update(msg) + a.dialog = dialog.(dialogs.DialogCmp) + cmds = append(cmds, cmd) - if a.showSessionDialog { - d, sessionCmd := a.sessionDialog.Update(msg) - a.sessionDialog = d.(dialog.SessionDialog) - cmds = append(cmds, sessionCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyMsg); ok { - return a, tea.Batch(cmds...) - } - } + return tea.Batch(cmds...) +} - if a.showCommandDialog { - d, commandCmd := a.commandDialog.Update(msg) - a.commandDialog = d.(dialog.CommandDialog) - cmds = append(cmds, commandCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyMsg); ok { - return a, tea.Batch(cmds...) +// handleKeyPressMsg processes keyboard input and routes to appropriate handlers. +func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { + switch { + // completions + case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up): + u, cmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return cmd + + case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down): + u, cmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return cmd + case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select): + u, cmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return cmd + case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel): + u, cmd := a.completions.Update(msg) + a.completions = u.(completions.Completions) + return cmd + // dialogs + case key.Matches(msg, a.keyMap.Quit): + if a.dialog.ActiveDialogId() == quit.QuitDialogID { + // if the quit dialog is already open, close the app + return tea.Quit } - } - - if a.showModelDialog { - d, modelCmd := a.modelDialog.Update(msg) - a.modelDialog = d.(dialog.ModelDialog) - cmds = append(cmds, modelCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyMsg); ok { - return a, tea.Batch(cmds...) + return util.CmdHandler(dialogs.OpenDialogMsg{ + Model: quit.NewQuitDialog(), + }) + + case key.Matches(msg, a.keyMap.Commands): + if a.dialog.ActiveDialogId() == commands.CommandsDialogID { + // If the commands dialog is already open, close it + return util.CmdHandler(dialogs.CloseDialogMsg{}) } - } - - if a.showInitDialog { - d, initCmd := a.initDialog.Update(msg) - a.initDialog = d.(dialog.InitDialogCmp) - cmds = append(cmds, initCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyMsg); ok { - return a, tea.Batch(cmds...) + return util.CmdHandler(dialogs.OpenDialogMsg{ + Model: commands.NewCommandDialog(a.selectedSessionID), + }) + case key.Matches(msg, a.keyMap.Sessions): + if a.dialog.ActiveDialogId() == sessions.SessionsDialogID { + // If the sessions dialog is already open, close it + return util.CmdHandler(dialogs.CloseDialogMsg{}) } - } - - if a.showThemeDialog { - d, themeCmd := a.themeDialog.Update(msg) - a.themeDialog = d.(dialog.ThemeDialog) - cmds = append(cmds, themeCmd) - // Only block key messages send all other messages down - if _, ok := msg.(tea.KeyMsg); ok { - return a, tea.Batch(cmds...) + var cmds []tea.Cmd + if a.dialog.ActiveDialogId() == commands.CommandsDialogID { + // If the commands dialog is open, close it first + cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{})) } - } - - s, _ := a.status.Update(msg) - a.status = s.(core.StatusCmp) - a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) - cmds = append(cmds, cmd) - return a, tea.Batch(cmds...) -} - -// RegisterCommand adds a command to the command dialog -func (a *appModel) RegisterCommand(cmd dialog.Command) { - a.commands = append(a.commands, cmd) -} + cmds = append(cmds, + func() tea.Msg { + allSessions, _ := a.app.Sessions.List(context.Background()) + return dialogs.OpenDialogMsg{ + Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID), + } + }, + ) + return tea.Sequence(cmds...) + // Page navigation + case key.Matches(msg, a.keyMap.Logs): + return a.moveToPage(logs.LogsPage) -func (a *appModel) findCommand(id string) (dialog.Command, bool) { - for _, cmd := range a.commands { - if cmd.ID == id { - return cmd, true + default: + if a.dialog.HasDialogs() { + u, dialogCmd := a.dialog.Update(msg) + a.dialog = u.(dialogs.DialogCmp) + return dialogCmd + } else { + updated, cmd := a.pages[a.currentPage].Update(msg) + a.pages[a.currentPage] = updated.(util.Model) + return cmd } } - return dialog.Command{}, false } +// moveToPage handles navigation between different pages in the application. func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { if a.app.CoderAgent.IsBusy() { // For now we don't move to any page if the agent is busy @@ -699,266 +367,68 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { return tea.Batch(cmds...) } -func (a appModel) View() string { +// View renders the complete application interface including pages, dialogs, and overlays. +func (a *appModel) View() tea.View { + pageView := a.pages[a.currentPage].View() components := []string{ - a.pages[a.currentPage].View(), + pageView.String(), } - - components = append(components, a.status.View()) + components = append(components, a.status.View().String()) appView := lipgloss.JoinVertical(lipgloss.Top, components...) - - if a.showPermissions { - overlay := a.permissions.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showFilepicker { - overlay := a.filepicker.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - - } - - // Show compacting status overlay - if a.isCompacting { - t := theme.CurrentTheme() - style := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocused()). - BorderBackground(t.Background()). - Padding(1, 2). - Background(t.Background()). - Foreground(t.Text()) - - overlay := style.Render("Summarizing\n" + a.compactingMessage) - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showHelp { - bindings := layout.KeyMapToSlice(keys) - if p, ok := a.pages[a.currentPage].(layout.Bindings); ok { - bindings = append(bindings, p.BindingKeys()...) - } - if a.showPermissions { - bindings = append(bindings, a.permissions.BindingKeys()...) - } - if a.currentPage == page.LogsPage { - bindings = append(bindings, logsKeyReturnKey) - } - if !a.app.CoderAgent.IsBusy() { - bindings = append(bindings, helpEsc) - } - a.help.SetBindings(bindings) - - overlay := a.help.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) + layers := []*lipgloss.Layer{ + lipgloss.NewLayer(appView), } - - if a.showQuit { - overlay := a.quit.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, + if a.dialog.HasDialogs() { + layers = append( + layers, + a.dialog.GetLayers()..., ) } - if a.showSessionDialog { - overlay := a.sessionDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) + cursor := pageView.Cursor() + activeView := a.dialog.ActiveView() + if activeView != nil { + cursor = activeView.Cursor() } - if a.showModelDialog { - overlay := a.modelDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, + if a.completions.Open() && cursor != nil { + cmp := a.completions.View().String() + x, y := a.completions.Position() + layers = append( + layers, + lipgloss.NewLayer(cmp).X(x).Y(y), ) } - if a.showCommandDialog { - overlay := a.commandDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showInitDialog { - overlay := a.initDialog.View() - appView = layout.PlaceOverlay( - a.width/2-lipgloss.Width(overlay)/2, - a.height/2-lipgloss.Height(overlay)/2, - overlay, - appView, - true, - ) - } + canvas := lipgloss.NewCanvas( + layers..., + ) - if a.showThemeDialog { - overlay := a.themeDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - if a.showMultiArgumentsDialog { - overlay := a.multiArgumentsDialog.View() - row := lipgloss.Height(appView) / 2 - row -= lipgloss.Height(overlay) / 2 - col := lipgloss.Width(appView) / 2 - col -= lipgloss.Width(overlay) / 2 - appView = layout.PlaceOverlay( - col, - row, - overlay, - appView, - true, - ) - } - - return appView + t := styles.CurrentTheme() + view := tea.NewView(canvas.Render()) + view.SetBackgroundColor(t.BgBase) + view.SetCursor(cursor) + return view } +// New creates and initializes a new TUI application model. func New(app *app.App) tea.Model { - startPage := page.ChatPage + startPage := chat.ChatPage model := &appModel{ - currentPage: startPage, - loadedPages: make(map[page.PageID]bool), - status: core.NewStatusCmp(app.LSPClients), - help: dialog.NewHelpCmp(), - quit: dialog.NewQuitCmp(), - sessionDialog: dialog.NewSessionDialogCmp(), - commandDialog: dialog.NewCommandDialogCmp(), - modelDialog: dialog.NewModelDialogCmp(), - permissions: dialog.NewPermissionDialogCmp(), - initDialog: dialog.NewInitDialogCmp(), - themeDialog: dialog.NewThemeDialogCmp(), - app: app, - commands: []dialog.Command{}, - pages: map[page.PageID]tea.Model{ - page.ChatPage: page.NewChatPage(app), - page.LogsPage: page.NewLogsPage(), - }, - filepicker: dialog.NewFilepickerCmp(app), - } - - model.RegisterCommand(dialog.Command{ - ID: "init", - Title: "Initialize Project", - Description: "Create/Update the OpenCode.md memory file", - Handler: func(cmd dialog.Command) tea.Cmd { - prompt := `Please analyze this codebase and create a OpenCode.md file containing: -1. Build/lint/test commands - especially for running a single test -2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. - -The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. -If there's already a opencode.md, improve it. -If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.` - return tea.Batch( - util.CmdHandler(chat.SendMsg{ - Text: prompt, - }), - ) + currentPage: startPage, + app: app, + status: status.NewStatusCmp(), + loadedPages: make(map[page.PageID]bool), + keyMap: DefaultKeyMap(), + + pages: map[page.PageID]util.Model{ + chat.ChatPage: chat.NewChatPage(app), + logs.LogsPage: logs.NewLogsPage(), }, - }) - model.RegisterCommand(dialog.Command{ - ID: "compact", - Title: "Compact Session", - Description: "Summarize the current session and create a new one with the summary", - Handler: func(cmd dialog.Command) tea.Cmd { - return func() tea.Msg { - return startCompactSessionMsg{} - } - }, - }) - // Load custom commands - customCommands, err := dialog.LoadCustomCommands() - if err != nil { - logging.Warn("Failed to load custom commands", "error", err) - } else { - for _, cmd := range customCommands { - model.RegisterCommand(cmd) - } + dialog: dialogs.NewDialogCmp(), + completions: completions.New(), } return model diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index 2707009b3747c058d3a1625803a4283ea529f4f5..8f7bb1bed15c184121cf5c0b16d9ba0cd98eb531 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -3,9 +3,14 @@ package util import ( "time" - tea "github.com/charmbracelet/bubbletea" + tea "github.com/charmbracelet/bubbletea/v2" ) +type Model interface { + tea.Model + tea.Viewable +} + func CmdHandler(msg tea.Msg) tea.Cmd { return func() tea.Msg { return msg diff --git a/internal/version/version.go b/internal/version/version.go index eefccec25dd3699f71767c330fd2c453ace9f7fa..a762fc8a47d9f4b837a53210408a0415546ab2af 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -5,7 +5,7 @@ import "runtime/debug" // Build-time parameters set via -ldflags var Version = "unknown" -// A user may install pug using `go install github.com/opencode-ai/opencode@latest`. +// A user may install pug using `go install github.com/charmbracelet/crush@latest`. // without -ldflags, in which case the version above is unset. As a workaround // we use the embedded build version that *is* set when using `go install` (and // is only set for `go install` and not for `go build`). diff --git a/main.go b/main.go index 857344ef52f4b4928e2ea0f794b8d9a1753c0616..a5305d08d7ae3ede818568d5cf825d1ce52bbf61 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,13 @@ package main import ( - "github.com/opencode-ai/opencode/cmd" - "github.com/opencode-ai/opencode/internal/logging" + "net/http" + "os" + + _ "net/http/pprof" // profiling + + "github.com/charmbracelet/crush/cmd" + "github.com/charmbracelet/crush/internal/logging" ) func main() { @@ -10,5 +15,14 @@ func main() { logging.ErrorPersist("Application terminated due to unhandled panic") }) + if os.Getenv("CRUSH_PROFILE") != "" { + go func() { + logging.Info("Serving pprof at localhost:6060") + if httpErr := http.ListenAndServe("localhost:6060", nil); httpErr != nil { + logging.Error("Failed to pprof listen: %v", httpErr) + } + }() + } + cmd.Execute() } diff --git a/todos.md b/todos.md new file mode 100644 index 0000000000000000000000000000000000000000..0397c1088aae74a99a4ccf1e2ec4bc12c3be0477 --- /dev/null +++ b/todos.md @@ -0,0 +1,29 @@ +# Chat Page + +## Landing page + +- [x] Implement the logo landing page +- [x] Add cwd improved +- [x] Implement Active LSPs +- [x] Implement Active MCPs + +## Dialogs + +- [x] Cleanup Commands +- [x] Sessions dialog +- [x] Models +- [x] Move sessions and model dialog to the commands +- [x] Add sessions shortuct +- [ ] Add all posible actions to the commands + +## Investigate + +- [ ] Events when tool error +- [ ] Fancy Spinner + +## Messages + +- [ ] Fix issue with numbers (padding) +- [ ] Run tools in parallel and add the responses in parallel +- [ ] Handle parallel permission calls +- [ ] Weird behavior sometimes the message does not update