From af838d1cd62ec03311f13812a0435d33f966f21e Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Fri, 16 May 2025 18:29:20 +0200
Subject: [PATCH 01/73] move to v2
---
cmd/root.go | 5 +-
go.mod | 21 +-
go.sum | 47 ++-
internal/diff/diff.go | 20 +-
internal/format/spinner.go | 4 +-
internal/tui/components/chat/chat.go | 2 +-
internal/tui/components/chat/editor.go | 41 ++-
internal/tui/components/chat/list.go | 24 +-
internal/tui/components/chat/message.go | 34 +-
internal/tui/components/chat/sidebar.go | 7 +-
internal/tui/components/core/status.go | 6 +-
internal/tui/components/dialog/arguments.go | 50 ++-
internal/tui/components/dialog/commands.go | 8 +-
internal/tui/components/dialog/complete.go | 10 +-
.../tui/components/dialog/custom_commands.go | 2 +-
internal/tui/components/dialog/filepicker.go | 26 +-
internal/tui/components/dialog/help.go | 13 +-
internal/tui/components/dialog/init.go | 6 +-
internal/tui/components/dialog/models.go | 9 +-
internal/tui/components/dialog/permission.go | 37 +-
internal/tui/components/dialog/quit.go | 10 +-
internal/tui/components/dialog/session.go | 11 +-
internal/tui/components/dialog/theme.go | 9 +-
internal/tui/components/logs/details.go | 23 +-
internal/tui/components/logs/table.go | 11 +-
internal/tui/components/util/simple-list.go | 9 +-
internal/tui/image/images.go | 5 +-
internal/tui/layout/container.go | 16 +-
internal/tui/layout/layout.go | 4 +-
internal/tui/layout/overlay.go | 4 +-
internal/tui/layout/split.go | 10 +-
internal/tui/page/chat.go | 12 +-
internal/tui/page/logs.go | 9 +-
internal/tui/styles/background.go | 123 -------
internal/tui/styles/markdown.go | 120 +++---
internal/tui/styles/styles.go | 40 +-
internal/tui/theme/catppuccin.go | 346 +++++++-----------
internal/tui/theme/dracula.go | 272 +++-----------
internal/tui/theme/flexoki.go | 340 +++++++----------
internal/tui/theme/gruvbox.go | 334 +++++++----------
internal/tui/theme/monokai.go | 334 +++++++----------
internal/tui/theme/onedark.go | 334 +++++++----------
internal/tui/theme/opencode.go | 343 +++++++----------
internal/tui/theme/theme.go | 325 ++++++++--------
internal/tui/theme/tokyonight.go | 334 +++++++----------
internal/tui/theme/tron.go | 334 +++++++----------
internal/tui/tui.go | 26 +-
internal/tui/util/util.go | 7 +-
48 files changed, 1591 insertions(+), 2526 deletions(-)
delete mode 100644 internal/tui/styles/background.go
diff --git a/cmd/root.go b/cmd/root.go
index 3a58cec4ed0914116f5c2a415540f0df8a0f143d..160be9dbec092493a6607326f9b9ea5304004d50 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -7,8 +7,7 @@ import (
"sync"
"time"
- tea "github.com/charmbracelet/bubbletea"
- zone "github.com/lrstanley/bubblezone"
+ tea "github.com/charmbracelet/bubbletea/v2"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/db"
@@ -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/go.mod b/go.mod
index 82994450a85848a8b1be260b8d1611dec023ac1d..2891cac0969dd2c7f9ae6b512cb0d42130ffe2c0 100644
--- a/go.mod
+++ b/go.mod
@@ -11,15 +11,14 @@ require (
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/charmbracelet/bubbles/v2 v2.0.0-beta.1
+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1
+ github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6
+ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40
+ github.com/charmbracelet/x/ansi v0.9.2
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
@@ -58,13 +57,15 @@ require (
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.0 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da // indirect
+ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
+ github.com/charmbracelet/x/input v0.3.4 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.0 // 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
@@ -81,7 +82,6 @@ require (
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
@@ -119,7 +119,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..dfa16aaaba641df5043e9d2af89b626cc34bfc17 100644
--- a/go.sum
+++ b/go.sum
@@ -68,24 +68,30 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
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/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc=
+github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
+github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
+github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6 h1:AKhOV8dSRU3KpqMgpGME9JU7ouumB2S6hMmD6PRJeTc=
+github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6/go.mod h1:7xBAUTCSADx9mHG0uBf4NDoVpYxMzIQ2j/NMLGdFsFM=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40 h1:SxOUomYAVo5zh+6WCH1bGshlAnSKP0ZeovI0FHAl9kg=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
+github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY=
+github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da h1:8MGKD5WBtuzfXglq0CnyzVSwGojv57X+H46OL9OUyRA=
+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+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-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
+github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
+github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
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.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
+github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
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=
@@ -96,8 +102,6 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
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=
@@ -146,16 +150,12 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
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=
@@ -297,7 +297,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 +317,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=
diff --git a/internal/diff/diff.go b/internal/diff/diff.go
index 8f5e669d3c2c6741ea6facaab3f447283ed5a5ab..6dcafa984cd052102807e8454e7bfe047cf08d5d 100644
--- a/internal/diff/diff.go
+++ b/internal/diff/diff.go
@@ -3,6 +3,7 @@ package diff
import (
"bytes"
"fmt"
+ "image/color"
"io"
"regexp"
"strconv"
@@ -13,7 +14,7 @@ import (
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/aymanbagabas/go-udiff"
- "github.com/charmbracelet/lipgloss"
+ "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"
@@ -323,7 +324,7 @@ func pairLines(lines []DiffLine) []linePair {
// -------------------------------------------------------------------------
// SyntaxHighlight applies syntax highlighting to text based on file extension
-func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipgloss.TerminalColor) error {
+func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error {
t := theme.CurrentTheme()
// Determine the language lexer to use
@@ -531,16 +532,13 @@ func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg lipglos
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 {
+func highlightLine(fileName string, line string, bg color.Color) string {
var buf bytes.Buffer
err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
if err != nil {
@@ -563,7 +561,7 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
// Rendering Functions
// -------------------------------------------------------------------------
-func lipglossToHex(color lipgloss.Color) string {
+func lipglossToHex(color color.Color) string {
r, g, b, a := color.RGBA()
// Scale uint32 values (0-65535) to uint8 (0-255).
@@ -576,7 +574,7 @@ func lipglossToHex(color lipgloss.Color) string {
}
// 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)
diff --git a/internal/format/spinner.go b/internal/format/spinner.go
index 083ee557f82ea903fb9d1d7be5da1da120744f6c..89eb9d25cacd28584575711508ec5f89b7ef163c 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
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index 1ad3f683d7a7e7f851c6e6696d4d7d4692f313bd..2aa7ee5d07f324ca45e80dc4fbab9964f05721bb 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -4,7 +4,7 @@ import (
"fmt"
"sort"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/message"
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index a6c5a44e8d96bdd6cc69a52f3f0ac95fad883fb3..4f3f69665a9af6d622214431ca563dff20764412 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -8,10 +8,10 @@ import (
"strings"
"unicode"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/textarea"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/textarea"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
@@ -162,7 +162,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
m.attachments = append(m.attachments, msg.Attachment)
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
m.deleteMode = true
return m, nil
@@ -172,8 +172,9 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.attachments = nil
return m, nil
}
- if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
- num := int(msg.Runes[0] - '0')
+ 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 {
@@ -286,14 +287,22 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
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)
+ s := textarea.DefaultDarkStyles()
+ b := s.Blurred
+ b.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
+ b.CursorLine = styles.BaseStyle().Background(bgColor)
+ b.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
+ b.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
+
+ f := s.Focused
+ f.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
+ f.CursorLine = styles.BaseStyle().Background(bgColor)
+ f.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
+ f.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
+
+ s.Focused = f
+ s.Blurred = b
+ ta.Styles = s
ta.Prompt = " "
ta.ShowLineNumbers = false
@@ -309,7 +318,7 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
return ta
}
-func NewEditorCmp(app *app.App) tea.Model {
+func NewEditorCmp(app *app.App) util.Model {
ta := CreateTextArea(nil)
return &editorCmp{
app: app,
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index 40d5b962876f09f60f44092f0c21b1f1ec9e4bb1..9cfb5c51cc91723fcedd3a03d1e11c173c33509d 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -5,11 +5,11 @@ import (
"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/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/spinner"
+ "github.com/charmbracelet/bubbles/v2/viewport"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/pubsub"
@@ -426,10 +426,10 @@ func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
}
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.viewport.SetWidth(width)
+ m.viewport.SetHeight(height - 2)
+ m.attachments.SetWidth(width + 40)
+ m.attachments.SetHeight(3)
m.rerender()
return nil
}
@@ -468,11 +468,11 @@ func (m *messagesCmp) BindingKeys() []key.Binding {
}
}
-func NewMessagesCmp(app *app.App) tea.Model {
+func NewMessagesCmp(app *app.App) util.Model {
s := spinner.New()
s.Spinner = spinner.Pulse
- vp := viewport.New(0, 0)
- attachmets := viewport.New(0, 0)
+ vp := viewport.New()
+ attachmets := viewport.New()
vp.KeyMap.PageUp = messageKeys.PageUp
vp.KeyMap.PageDown = messageKeys.PageDown
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go
index 0732366d94c01dc8d183f97dcebbe8a220554f74..f1fdda7265cb1697f10d68cde45c9d0563ecbed3 100644
--- a/internal/tui/components/chat/message.go
+++ b/internal/tui/components/chat/message.go
@@ -8,7 +8,7 @@ import (
"strings"
"time"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/diff"
@@ -60,7 +60,7 @@ func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...s
// Apply markdown formatting and handle background color
parts := []string{
- styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()),
+ toMarkdown(msg, isFocused, width),
}
// Remove newline at the end
@@ -454,16 +454,10 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
resultContent := truncateHeight(response.Content, maxResultHeight)
switch toolCall.Name {
case agent.AgentToolName:
- return styles.ForceReplaceBackgroundWithLipgloss(
- toMarkdown(resultContent, false, width),
- t.Background(),
- )
+ return toMarkdown(resultContent, false, width)
case tools.BashToolName:
resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
- return styles.ForceReplaceBackgroundWithLipgloss(
- toMarkdown(resultContent, true, width),
- t.Background(),
- )
+ return toMarkdown(resultContent, true, width)
case tools.EditToolName:
metadata := tools.EditResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
@@ -481,10 +475,7 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
mdFormat = "html"
}
resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
- return styles.ForceReplaceBackgroundWithLipgloss(
- toMarkdown(resultContent, true, width),
- t.Background(),
- )
+ return toMarkdown(resultContent, true, width)
case tools.GlobToolName:
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
case tools.GrepToolName:
@@ -503,10 +494,7 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
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(),
- )
+ return toMarkdown(resultContent, true, width)
case tools.WriteToolName:
params := tools.WriteParams{}
json.Unmarshal([]byte(toolCall.Input), ¶ms)
@@ -519,16 +507,10 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
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(),
- )
+ return toMarkdown(resultContent, true, width)
default:
resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
- return styles.ForceReplaceBackgroundWithLipgloss(
- toMarkdown(resultContent, true, width),
- t.Background(),
- )
+ return toMarkdown(resultContent, true, width)
}
}
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
index a66249b368cd28274333395cb79a5b127b3fb8f9..b54769038c508894f0fa289aadcba9e82b3f189f 100644
--- a/internal/tui/components/chat/sidebar.go
+++ b/internal/tui/components/chat/sidebar.go
@@ -6,8 +6,8 @@ import (
"sort"
"strings"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/diff"
"github.com/opencode-ai/opencode/internal/history"
@@ -15,6 +15,7 @@ import (
"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/tui/util"
)
type sidebarCmp struct {
@@ -235,7 +236,7 @@ func (m *sidebarCmp) GetSize() (int, int) {
return m.width, m.height
}
-func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
+func NewSidebarCmp(session session.Session, history history.Service) util.Model {
return &sidebarCmp{
session: session,
history: history,
diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go
index 0dc227a80ebbbcdd8a4e3578a11b1fe6f64aca4a..037bd7417897a75f62feb16eca90b81a82360648 100644
--- a/internal/tui/components/core/status.go
+++ b/internal/tui/components/core/status.go
@@ -5,8 +5,8 @@ import (
"strings"
"time"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/lsp"
@@ -20,7 +20,7 @@ import (
)
type StatusCmp interface {
- tea.Model
+ util.Model
}
type statusCmp struct {
diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go
index 684d8662fcc357adb33f873da9eadab1d3ca6a67..a988407453322267c7cf96fa626b09f6b2ac36fd 100644
--- a/internal/tui/components/dialog/arguments.go
+++ b/internal/tui/components/dialog/arguments.go
@@ -2,10 +2,11 @@ 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/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/textinput"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -70,17 +71,18 @@ func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) Mu
for i, name := range argNames {
ti := textinput.New()
ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
- ti.Width = 40
+ ti.SetWidth(40)
ti.Prompt = ""
- ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
- ti.PromptStyle = ti.PromptStyle.Background(t.Background())
- ti.TextStyle = ti.TextStyle.Background(t.Background())
-
+ ti.Styles.Focused.Placeholder = ti.Styles.Focused.Placeholder.Background(t.Background())
+ ti.Styles.Blurred.Placeholder = ti.Styles.Blurred.Placeholder.Background(t.Background())
+ ti.Styles.Focused.Suggestion = ti.Styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
+ ti.Styles.Blurred.Suggestion = ti.Styles.Blurred.Suggestion.Background(t.Background())
+ ti.Styles.Focused.Text = ti.Styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
+ ti.Styles.Blurred.Text = ti.Styles.Blurred.Text.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()
}
@@ -89,11 +91,11 @@ func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) Mu
}
return MultiArgumentsDialogCmp{
- inputs: inputs,
- keys: argumentsDialogKeyMap{},
- commandID: commandID,
- content: content,
- argNames: argNames,
+ inputs: inputs,
+ keys: argumentsDialogKeyMap{},
+ commandID: commandID,
+ content: content,
+ argNames: argNames,
focusIndex: 0,
}
}
@@ -108,15 +110,13 @@ func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
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 {
@@ -145,22 +145,16 @@ func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@@ -206,13 +200,13 @@ func (m MultiArgumentsDialogCmp) View() string {
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().
@@ -254,4 +248,4 @@ func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
// 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
index 25069b8a6dd39633e46b8627ff4a066bc52b1239..695f94da6191dcc5015b954e9890be37bb0d03fa 100644
--- a/internal/tui/components/dialog/commands.go
+++ b/internal/tui/components/dialog/commands.go
@@ -1,9 +1,9 @@
package dialog
import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
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"
@@ -56,7 +56,7 @@ type CloseCommandDialogMsg struct{}
// CommandDialog interface for the command selection dialog
type CommandDialog interface {
- tea.Model
+ util.Model
layout.Bindings
SetCommands(commands []Command)
}
diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go
index 1ce66e12ae79eee479c45a7e6138123f1c77879c..cda4636be44ac637ffb8172015c7863c422fcde7 100644
--- a/internal/tui/components/dialog/complete.go
+++ b/internal/tui/components/dialog/complete.go
@@ -1,10 +1,10 @@
package dialog
import (
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/textarea"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/textarea"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"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"
@@ -77,7 +77,7 @@ type CompletionDialogCompleteItemMsg struct {
type CompletionDialogCloseMsg struct{}
type CompletionDialog interface {
- tea.Model
+ util.Model
layout.Bindings
SetWidth(width int)
}
diff --git a/internal/tui/components/dialog/custom_commands.go b/internal/tui/components/dialog/custom_commands.go
index 049c4735b5bc4302da87d349d885756882e3c14f..cd1ed3988ea10ff35400f90e427b9b054e6348cd 100644
--- a/internal/tui/components/dialog/custom_commands.go
+++ b/internal/tui/components/dialog/custom_commands.go
@@ -7,7 +7,7 @@ import (
"regexp"
"strings"
- tea "github.com/charmbracelet/bubbletea"
+ tea "github.com/charmbracelet/bubbletea/v2"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/tui/util"
)
diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go
index 3b9a0dc6c39a090e084b010a4ac5640f338a4836..2955c27514eb3eeb8cf7ec1c48a9b5e31d2a6c84 100644
--- a/internal/tui/components/dialog/filepicker.go
+++ b/internal/tui/components/dialog/filepicker.go
@@ -9,11 +9,11 @@ import (
"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/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/textinput"
+ "github.com/charmbracelet/bubbles/v2/viewport"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
@@ -122,8 +122,8 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
f.width = 60
f.height = 20
- f.viewport.Width = 80
- f.viewport.Height = 22
+ f.viewport.SetWidth(80)
+ f.viewport.SetHeight(22)
f.cursor = 0
f.getCurrentFileBelowCursor()
case tea.KeyMsg:
@@ -319,7 +319,7 @@ func (f *filepickerCmp) View() string {
Render(f.cwd.View())
viewportstyle := lipgloss.NewStyle().
- Width(f.viewport.Width).
+ Width(f.viewport.Width()).
Background(t.Background()).
Border(lipgloss.RoundedBorder()).
BorderForeground(t.TextMuted()).
@@ -353,7 +353,7 @@ func (f *filepickerCmp) View() string {
}
type FilepickerCmp interface {
- tea.Model
+ util.Model
ToggleFilepicker(showFilepicker bool)
IsCWDFocused() bool
}
@@ -374,11 +374,11 @@ func NewFilepickerCmp(app *app.App) FilepickerCmp {
}
baseDir := DirNode{parent: nil, directory: homepath}
dirs := readDir(homepath, false)
- viewport := viewport.New(0, 0)
+ viewport := viewport.New()
currentDirectory := textinput.New()
currentDirectory.CharLimit = 200
- currentDirectory.Width = 44
- currentDirectory.Cursor.Blink = true
+ currentDirectory.SetWidth(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}
}
@@ -396,7 +396,7 @@ func (f *filepickerCmp) getCurrentFileBelowCursor() {
fullPath := f.cwdDetails.directory + "/" + dir.Name()
go func() {
- imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
+ imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
if err != nil {
logging.Error(err.Error())
f.viewport.SetContent("Preview unavailable")
diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go
index 90959ad2ed540ac23e3285da0380c7b88635f708..63416f4250548c51cce14308b6efac617221a2c2 100644
--- a/internal/tui/components/dialog/help.go
+++ b/internal/tui/components/dialog/help.go
@@ -3,11 +3,12 @@ package dialog
import (
"strings"
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"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 helpCmp struct {
@@ -134,7 +135,7 @@ func (h *helpCmp) render() string {
pairs = append(pairs, pair)
}
- // https://github.com/charmbracelet/lipgloss/issues/209
+ // https://github.com/charmbracelet/lipgloss/v2/issues/209
if len(pairs) > 1 {
prefix := pairs[:len(pairs)-1]
lastPair := pairs[len(pairs)-1]
@@ -144,7 +145,7 @@ func (h *helpCmp) render() string {
lipgloss.Left, // x
lipgloss.Top, // y
lastPair, // content
- lipgloss.WithWhitespaceBackground(t.Background()),
+ lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
))
content := baseStyle.Width(h.width).Render(
lipgloss.JoinHorizontal(
@@ -191,7 +192,7 @@ func (h *helpCmp) View() string {
}
type HelpCmp interface {
- tea.Model
+ util.Model
SetBindings([]key.Binding)
}
diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go
index 77c76584d9bf1abaceb2206c3f577c8834c3cc04..1224cbe10ad7dd54811ea1db4b16cddb718544a2 100644
--- a/internal/tui/components/dialog/init.go
+++ b/internal/tui/components/dialog/init.go
@@ -1,9 +1,9 @@
package dialog
import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go
index 77c2a02ac1979d7ad5fe0c8f4845f73f6ea36e81..c17f87cde3c5a3f066c7a5371fbff66f2a8c7d09 100644
--- a/internal/tui/components/dialog/models.go
+++ b/internal/tui/components/dialog/models.go
@@ -5,9 +5,9 @@ import (
"slices"
"strings"
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/tui/layout"
@@ -31,7 +31,7 @@ type CloseModelDialogMsg struct{}
// ModelDialog interface for the model selection dialog
type ModelDialog interface {
- tea.Model
+ util.Model
layout.Bindings
}
@@ -281,7 +281,6 @@ func (m *modelDialogCmp) setupModels() {
}
func GetSelectedModel(cfg *config.Config) models.Model {
-
agentCfg := cfg.Agents[config.AgentCoder]
selectedModelId := agentCfg.Model
return models.SupportedModels[selectedModelId]
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index 6c135098a7ade938e7bec1a69b4a3a6f5db79d6f..16c53266383d3cdfc1d5f8bda096b099b7bb170a 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -4,10 +4,10 @@ import (
"fmt"
"strings"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/viewport"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/viewport"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/diff"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/permission"
@@ -34,7 +34,7 @@ type PermissionResponseMsg struct {
// PermissionDialogCmp interface for permission dialog component
type PermissionDialogCmp interface {
- tea.Model
+ util.Model
layout.Bindings
SetPermissions(permission permission.PermissionRequest) tea.Cmd
}
@@ -268,7 +268,6 @@ func (p *permissionDialogCmp) renderHeader() string {
}
func (p *permissionDialogCmp) renderBashContent() string {
- t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
@@ -278,11 +277,11 @@ func (p *permissionDialogCmp) renderBashContent() string {
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
+ return s, err
})
finalContent := baseStyle.
- Width(p.contentViewPort.Width).
+ Width(p.contentViewPort.Width()).
Render(renderedContent)
p.contentViewPort.SetContent(finalContent)
return p.styleViewport()
@@ -293,7 +292,7 @@ func (p *permissionDialogCmp) renderBashContent() string {
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))
+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
})
p.contentViewPort.SetContent(diff)
@@ -305,7 +304,7 @@ func (p *permissionDialogCmp) renderEditContent() string {
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))
+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
})
p.contentViewPort.SetContent(diff)
@@ -318,7 +317,7 @@ 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))
+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
})
p.contentViewPort.SetContent(diff)
@@ -328,7 +327,6 @@ func (p *permissionDialogCmp) renderWriteContent() string {
}
func (p *permissionDialogCmp) renderFetchContent() string {
- t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
@@ -338,11 +336,11 @@ func (p *permissionDialogCmp) renderFetchContent() string {
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
+ return s, err
})
finalContent := baseStyle.
- Width(p.contentViewPort.Width).
+ Width(p.contentViewPort.Width()).
Render(renderedContent)
p.contentViewPort.SetContent(finalContent)
return p.styleViewport()
@@ -351,7 +349,6 @@ func (p *permissionDialogCmp) renderFetchContent() string {
}
func (p *permissionDialogCmp) renderDefaultContent() string {
- t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
content := p.permission.Description
@@ -360,11 +357,11 @@ func (p *permissionDialogCmp) renderDefaultContent() string {
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
+ return s, err
})
finalContent := baseStyle.
- Width(p.contentViewPort.Width).
+ Width(p.contentViewPort.Width()).
Render(renderedContent)
p.contentViewPort.SetContent(finalContent)
@@ -398,8 +395,8 @@ func (p *permissionDialogCmp) render() string {
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
+ p.contentViewPort.SetHeight(p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title))
+ p.contentViewPort.SetWidth(p.width - 4)
// Render content based on tool type
var contentFinal string
@@ -511,7 +508,7 @@ func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (str
func NewPermissionDialogCmp() PermissionDialogCmp {
// Create viewport for content
- contentViewport := viewport.New(0, 0)
+ contentViewport := viewport.New()
return &permissionDialogCmp{
contentViewPort: contentViewport,
diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go
index f755fa272547657c84a40125421d9e7411a1530e..0331a9ac5fe433b0a02b39a1f498ce8a28d4bb78 100644
--- a/internal/tui/components/dialog/quit.go
+++ b/internal/tui/components/dialog/quit.go
@@ -3,9 +3,9 @@ package dialog
import (
"strings"
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -17,7 +17,7 @@ const question = "Are you sure you want to quit?"
type CloseQuitMsg struct{}
type QuitDialog interface {
- tea.Model
+ util.Model
layout.Bindings
}
@@ -84,7 +84,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (q *quitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
-
+
yesStyle := baseStyle
noStyle := baseStyle
spacerStyle := baseStyle.Background(t.Background())
diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go
index a29fa7131ed1b1abbe7ae170eb2af107ad5c5647..60b5b8f360bc0383bde7e38ac4fa14ecf99d4fd1 100644
--- a/internal/tui/components/dialog/session.go
+++ b/internal/tui/components/dialog/session.go
@@ -1,9 +1,9 @@
package dialog
import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
@@ -21,7 +21,7 @@ type CloseSessionDialogMsg struct{}
// SessionDialog interface for the session switching dialog
type SessionDialog interface {
- tea.Model
+ util.Model
layout.Bindings
SetSessions(sessions []session.Session)
SetSelectedSession(sessionID string)
@@ -108,7 +108,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (s *sessionDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
-
+
if len(s.sessions) == 0 {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
@@ -181,7 +181,6 @@ func (s *sessionDialogCmp) View() string {
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4).
Render(content)
}
diff --git a/internal/tui/components/dialog/theme.go b/internal/tui/components/dialog/theme.go
index d35d3e2b6df04ca6049728224533293ba0ef2285..29e854d3d6b2db07a4aaaa3ebc218e4e6d1dfaf5 100644
--- a/internal/tui/components/dialog/theme.go
+++ b/internal/tui/components/dialog/theme.go
@@ -1,9 +1,9 @@
package dialog
import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -20,7 +20,7 @@ type CloseThemeDialogMsg struct{}
// ThemeDialog interface for the theme switching dialog
type ThemeDialog interface {
- tea.Model
+ util.Model
layout.Bindings
}
@@ -195,4 +195,3 @@ func NewThemeDialogCmp() ThemeDialog {
currentTheme: "",
}
}
-
diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go
index 9d7713bbf048beea68dc0d007af948df2b2a6127..875395cfe5c535eba8be73a3eccc92f4f666dc53 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/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/viewport"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"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/opencode-ai/opencode/internal/tui/util"
)
type DetailComponent interface {
- tea.Model
+ util.Model
layout.Sizeable
layout.Bindings
}
@@ -99,7 +99,7 @@ func (i *detailCmp) updateContent() {
func getLevelStyle(level string) lipgloss.Style {
style := lipgloss.NewStyle().Bold(true)
t := theme.CurrentTheme()
-
+
switch strings.ToLower(level) {
case "info":
return style.Foreground(t.Info())
@@ -115,8 +115,7 @@ func getLevelStyle(level string) lipgloss.Style {
}
func (i *detailCmp) View() string {
- t := theme.CurrentTheme()
- return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), t.Background())
+ return i.viewport.View()
}
func (i *detailCmp) GetSize() (int, int) {
@@ -126,8 +125,8 @@ func (i *detailCmp) GetSize() (int, int) {
func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
i.width = width
i.height = height
- i.viewport.Width = i.width
- i.viewport.Height = i.height
+ i.viewport.SetWidth(i.width)
+ i.viewport.SetHeight(i.height)
i.updateContent()
return nil
}
@@ -138,6 +137,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..689ea087406cb64be62d06c4beff71fce6483304 100644
--- a/internal/tui/components/logs/table.go
+++ b/internal/tui/components/logs/table.go
@@ -4,19 +4,18 @@ import (
"encoding/json"
"slices"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/table"
- tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/table"
+ tea "github.com/charmbracelet/bubbletea/v2"
"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"
)
type TableComponent interface {
- tea.Model
+ util.Model
layout.Sizeable
layout.Bindings
}
@@ -66,7 +65,7 @@ func (i *tableCmp) View() string {
defaultStyles := table.DefaultStyles()
defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
i.table.SetStyles(defaultStyles)
- return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), t.Background())
+ return i.table.View()
}
func (i *tableCmp) GetSize() (int, int) {
diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go
index 7aad2494c6f93f084d52c3e11fa80d5b795ca217..541bfe5b297049b1479f6834fa9d3cdcb292a488 100644
--- a/internal/tui/components/util/simple-list.go
+++ b/internal/tui/components/util/simple-list.go
@@ -1,12 +1,13 @@
package utilComponents
import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"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 SimpleListItem interface {
@@ -14,7 +15,7 @@ type SimpleListItem interface {
}
type SimpleList[T SimpleListItem] interface {
- tea.Model
+ util.Model
layout.Bindings
SetMaxWidth(maxWidth int)
GetSelectedItem() (item T, idx int)
diff --git a/internal/tui/image/images.go b/internal/tui/image/images.go
index d10a169fd5d46d37b75a66fe6ad17f1bb0c284f2..72ce2b38f069deff64a367dc89003489d92a498c 100644
--- a/internal/tui/image/images.go
+++ b/internal/tui/image/images.go
@@ -3,10 +3,11 @@ package image
import (
"fmt"
"image"
+ "image/color"
"os"
"strings"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/disintegration/imaging"
"github.com/lucasb-eyer/go-colorful"
)
@@ -36,7 +37,7 @@ func ToString(width int, img image.Image) string {
c1, _ := colorful.MakeColor(img.At(x, heightCounter))
color1 := lipgloss.Color(c1.Hex())
- var color2 lipgloss.Color
+ var color2 color.Color
if heightCounter+1 < h {
c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
color2 = lipgloss.Color(c2.Hex())
diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go
index 83aef587938cc1f7ed56690bab98e10c9351b350..81e331c3c27fc2a3fbd3e9010516facce681d683 100644
--- a/internal/tui/layout/container.go
+++ b/internal/tui/layout/container.go
@@ -1,14 +1,15 @@
package layout
import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/util"
)
type Container interface {
- tea.Model
+ util.Model
Sizeable
Bindings
}
@@ -16,7 +17,7 @@ type container struct {
width int
height int
- content tea.Model
+ content util.Model
// Style options
paddingTop int
@@ -37,7 +38,7 @@ func (c *container) Init() tea.Cmd {
func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
u, cmd := c.content.Update(msg)
- c.content = u
+ c.content = u.(util.Model)
return c, cmd
}
@@ -123,8 +124,7 @@ func (c *container) BindingKeys() []key.Binding {
type ContainerOption func(*container)
-func NewContainer(content tea.Model, options ...ContainerOption) Container {
-
+func NewContainer(content util.Model, options ...ContainerOption) Container {
c := &container{
content: content,
borderStyle: lipgloss.NormalBorder(),
diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go
index 495a3fbc5140917b35c342e96672aa4dd8ee4b18..08aa3173ef89230a31962965d7607e4c043829c5 100644
--- a/internal/tui/layout/layout.go
+++ b/internal/tui/layout/layout.go
@@ -3,8 +3,8 @@ package layout
import (
"reflect"
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
)
type Focusable interface {
diff --git a/internal/tui/layout/overlay.go b/internal/tui/layout/overlay.go
index 3a14dbc5eeb9d9245dfd1b1c6a4ddda7f791b5f1..4c9dd5a9435e7d3d9a58a0b0829526532d76f2e0 100644
--- a/internal/tui/layout/overlay.go
+++ b/internal/tui/layout/overlay.go
@@ -3,7 +3,7 @@ package layout
import (
"strings"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
chAnsi "github.com/charmbracelet/x/ansi"
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
@@ -14,7 +14,7 @@ import (
)
// Most of this code is borrowed from
-// https://github.com/charmbracelet/lipgloss/pull/102
+// https://github.com/charmbracelet/lipgloss/v2/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
diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go
index 2684a8447cbe4fec4e3d389cf148cec622bfd72b..c40a6a0e79771ec8ea669382a99aa387384abc58 100644
--- a/internal/tui/layout/split.go
+++ b/internal/tui/layout/split.go
@@ -1,14 +1,15 @@
package layout
import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/util"
)
type SplitPaneLayout interface {
- tea.Model
+ util.Model
Sizeable
Bindings
SetLeftPanel(panel Container) tea.Cmd
@@ -241,7 +242,6 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
}
func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
-
layout := &splitPaneLayout{
ratio: 0.7,
verticalRatio: 0.9, // Default 90% for top section, 10% for bottom
diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go
index d297a34c2c1c828315b688c5fd4b60410cd97704..7096d7d159e2f86c37d26fc74d36b2249cd72f6f 100644
--- a/internal/tui/page/chat.go
+++ b/internal/tui/page/chat.go
@@ -4,9 +4,9 @@ import (
"context"
"strings"
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/completions"
"github.com/opencode-ai/opencode/internal/message"
@@ -76,7 +76,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
@@ -86,7 +86,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
content = strings.ReplaceAll(content, placeholder, value)
}
}
-
+
// Handle custom command execution
cmd := p.sendMessage(content, nil)
if cmd != nil {
@@ -212,7 +212,7 @@ func (p *chatPage) BindingKeys() []key.Binding {
return bindings
}
-func NewChatPage(app *app.App) tea.Model {
+func NewChatPage(app *app.App) util.Model {
cg := completions.NewFileAndFolderContextGroup()
completionDialog := dialog.NewCompletionDialogCmp(cg)
diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go
index 9bd545287f4c35c1ea99d33efd5f274275d63a4f..89c69b8654672987b4c49c1dec000fd83fb62031 100644
--- a/internal/tui/page/logs.go
+++ b/internal/tui/page/logs.go
@@ -1,18 +1,19 @@
package page
import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"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"
+ "github.com/opencode-ai/opencode/internal/tui/util"
)
var LogsPage PageID = "logs"
type LogPage interface {
- tea.Model
+ util.Model
layout.Sizeable
layout.Bindings
}
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/markdown.go b/internal/tui/styles/markdown.go
index 6b43d97cfeeea6829d298816b6f90f9d61e91629..bf9114d35ad9b2d36f742deb622a64210772bf20 100644
--- a/internal/tui/styles/markdown.go
+++ b/internal/tui/styles/markdown.go
@@ -1,9 +1,11 @@
package styles
import (
- "github.com/charmbracelet/glamour"
- "github.com/charmbracelet/glamour/ansi"
- "github.com/charmbracelet/lipgloss"
+ "fmt"
+ "image/color"
+
+ "github.com/charmbracelet/glamour/v2"
+ "github.com/charmbracelet/glamour/v2/ansi"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
@@ -33,13 +35,13 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "",
BlockSuffix: "",
- Color: stringPtr(adaptiveColorToString(t.MarkdownText())),
+ Color: stringPtr(colorToString(t.MarkdownText())),
},
Margin: uintPtr(defaultMargin),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownBlockQuote())),
+ Color: stringPtr(colorToString(t.MarkdownBlockQuote())),
Italic: boolPtr(true),
Prefix: "┃ ",
},
@@ -51,82 +53,82 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
StyleBlock: ansi.StyleBlock{
IndentToken: stringPtr(BaseStyle().Render(" ")),
StylePrimitive: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownText())),
+ Color: stringPtr(colorToString(t.MarkdownText())),
},
},
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
- Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())),
+ Color: stringPtr(colorToString(t.MarkdownHeading())),
Bold: boolPtr(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "# ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())),
+ Color: stringPtr(colorToString(t.MarkdownHeading())),
Bold: boolPtr(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())),
+ Color: stringPtr(colorToString(t.MarkdownHeading())),
Bold: boolPtr(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())),
+ Color: stringPtr(colorToString(t.MarkdownHeading())),
Bold: boolPtr(true),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())),
+ Color: stringPtr(colorToString(t.MarkdownHeading())),
Bold: boolPtr(true),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())),
+ Color: stringPtr(colorToString(t.MarkdownHeading())),
Bold: boolPtr(true),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())),
+ Color: stringPtr(colorToString(t.MarkdownHeading())),
Bold: boolPtr(true),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: boolPtr(true),
- Color: stringPtr(adaptiveColorToString(t.TextMuted())),
+ Color: stringPtr(colorToString(t.TextMuted())),
},
Emph: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownEmph())),
+ Color: stringPtr(colorToString(t.MarkdownEmph())),
Italic: boolPtr(true),
},
Strong: ansi.StylePrimitive{
Bold: boolPtr(true),
- Color: stringPtr(adaptiveColorToString(t.MarkdownStrong())),
+ Color: stringPtr(colorToString(t.MarkdownStrong())),
},
HorizontalRule: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownHorizontalRule())),
+ Color: stringPtr(colorToString(t.MarkdownHorizontalRule())),
Format: "\n─────────────────────────────────────────\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownListItem())),
+ Color: stringPtr(colorToString(t.MarkdownListItem())),
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownListEnumeration())),
+ Color: stringPtr(colorToString(t.MarkdownListEnumeration())),
},
Task: ansi.StyleTask{
StylePrimitive: ansi.StylePrimitive{},
@@ -134,25 +136,25 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownLink())),
+ Color: stringPtr(colorToString(t.MarkdownLink())),
Underline: boolPtr(true),
},
LinkText: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownLinkText())),
+ Color: stringPtr(colorToString(t.MarkdownLinkText())),
Bold: boolPtr(true),
},
Image: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownImage())),
+ Color: stringPtr(colorToString(t.MarkdownImage())),
Underline: boolPtr(true),
Format: "🖼 {{.text}}",
},
ImageText: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownImageText())),
+ Color: stringPtr(colorToString(t.MarkdownImageText())),
Format: "{{.text}}",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownCode())),
+ Color: stringPtr(colorToString(t.MarkdownCode())),
Prefix: "",
Suffix: "",
},
@@ -161,90 +163,90 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownCodeBlock())),
+ Color: stringPtr(colorToString(t.MarkdownCodeBlock())),
},
Margin: uintPtr(defaultMargin),
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownText())),
+ Color: stringPtr(colorToString(t.MarkdownText())),
},
Error: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.Error())),
+ Color: stringPtr(colorToString(t.Error())),
},
Comment: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxComment())),
+ Color: stringPtr(colorToString(t.SyntaxComment())),
},
CommentPreproc: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
+ Color: stringPtr(colorToString(t.SyntaxKeyword())),
},
Keyword: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
+ Color: stringPtr(colorToString(t.SyntaxKeyword())),
},
KeywordReserved: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
+ Color: stringPtr(colorToString(t.SyntaxKeyword())),
},
KeywordNamespace: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
+ Color: stringPtr(colorToString(t.SyntaxKeyword())),
},
KeywordType: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxType())),
+ Color: stringPtr(colorToString(t.SyntaxType())),
},
Operator: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxOperator())),
+ Color: stringPtr(colorToString(t.SyntaxOperator())),
},
Punctuation: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxPunctuation())),
+ Color: stringPtr(colorToString(t.SyntaxPunctuation())),
},
Name: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxVariable())),
+ Color: stringPtr(colorToString(t.SyntaxVariable())),
},
NameBuiltin: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxVariable())),
+ Color: stringPtr(colorToString(t.SyntaxVariable())),
},
NameTag: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
+ Color: stringPtr(colorToString(t.SyntaxKeyword())),
},
NameAttribute: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxFunction())),
+ Color: stringPtr(colorToString(t.SyntaxFunction())),
},
NameClass: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxType())),
+ Color: stringPtr(colorToString(t.SyntaxType())),
},
NameConstant: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxVariable())),
+ Color: stringPtr(colorToString(t.SyntaxVariable())),
},
NameDecorator: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxFunction())),
+ Color: stringPtr(colorToString(t.SyntaxFunction())),
},
NameFunction: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxFunction())),
+ Color: stringPtr(colorToString(t.SyntaxFunction())),
},
LiteralNumber: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxNumber())),
+ Color: stringPtr(colorToString(t.SyntaxNumber())),
},
LiteralString: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxString())),
+ Color: stringPtr(colorToString(t.SyntaxString())),
},
LiteralStringEscape: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.SyntaxKeyword())),
+ Color: stringPtr(colorToString(t.SyntaxKeyword())),
},
GenericDeleted: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.DiffRemoved())),
+ Color: stringPtr(colorToString(t.DiffRemoved())),
},
GenericEmph: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownEmph())),
+ Color: stringPtr(colorToString(t.MarkdownEmph())),
Italic: boolPtr(true),
},
GenericInserted: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.DiffAdded())),
+ Color: stringPtr(colorToString(t.DiffAdded())),
},
GenericStrong: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownStrong())),
+ Color: stringPtr(colorToString(t.MarkdownStrong())),
Bold: boolPtr(true),
},
GenericSubheading: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownHeading())),
+ Color: stringPtr(colorToString(t.MarkdownHeading())),
},
},
},
@@ -261,24 +263,20 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n ❯ ",
- Color: stringPtr(adaptiveColorToString(t.MarkdownLinkText())),
+ Color: stringPtr(colorToString(t.MarkdownLinkText())),
},
Text: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownText())),
+ Color: stringPtr(colorToString(t.MarkdownText())),
},
Paragraph: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
- Color: stringPtr(adaptiveColorToString(t.MarkdownText())),
+ Color: stringPtr(colorToString(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
+func colorToString(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/tui/styles/styles.go b/internal/tui/styles/styles.go
index 7094b537318843db3b453b194c3617fc219e1c89..a502e411c74c52b6f4521164f18710213ed62b03 100644
--- a/internal/tui/styles/styles.go
+++ b/internal/tui/styles/styles.go
@@ -1,13 +1,13 @@
package styles
import (
- "github.com/charmbracelet/lipgloss"
+ "image/color"
+
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
-var (
- ImageBakcground = "#212121"
-)
+var ImageBakcground = "#212121"
// Style generation functions that use the current theme
@@ -75,81 +75,81 @@ func DimBorder() lipgloss.Style {
}
// PrimaryColor returns the primary color from the current theme
-func PrimaryColor() lipgloss.AdaptiveColor {
+func PrimaryColor() color.Color {
return theme.CurrentTheme().Primary()
}
// SecondaryColor returns the secondary color from the current theme
-func SecondaryColor() lipgloss.AdaptiveColor {
+func SecondaryColor() color.Color {
return theme.CurrentTheme().Secondary()
}
// AccentColor returns the accent color from the current theme
-func AccentColor() lipgloss.AdaptiveColor {
+func AccentColor() color.Color {
return theme.CurrentTheme().Accent()
}
// ErrorColor returns the error color from the current theme
-func ErrorColor() lipgloss.AdaptiveColor {
+func ErrorColor() color.Color {
return theme.CurrentTheme().Error()
}
// WarningColor returns the warning color from the current theme
-func WarningColor() lipgloss.AdaptiveColor {
+func WarningColor() color.Color {
return theme.CurrentTheme().Warning()
}
// SuccessColor returns the success color from the current theme
-func SuccessColor() lipgloss.AdaptiveColor {
+func SuccessColor() color.Color {
return theme.CurrentTheme().Success()
}
// InfoColor returns the info color from the current theme
-func InfoColor() lipgloss.AdaptiveColor {
+func InfoColor() color.Color {
return theme.CurrentTheme().Info()
}
// TextColor returns the text color from the current theme
-func TextColor() lipgloss.AdaptiveColor {
+func TextColor() color.Color {
return theme.CurrentTheme().Text()
}
// TextMutedColor returns the muted text color from the current theme
-func TextMutedColor() lipgloss.AdaptiveColor {
+func TextMutedColor() color.Color {
return theme.CurrentTheme().TextMuted()
}
// TextEmphasizedColor returns the emphasized text color from the current theme
-func TextEmphasizedColor() lipgloss.AdaptiveColor {
+func TextEmphasizedColor() color.Color {
return theme.CurrentTheme().TextEmphasized()
}
// BackgroundColor returns the background color from the current theme
-func BackgroundColor() lipgloss.AdaptiveColor {
+func BackgroundColor() color.Color {
return theme.CurrentTheme().Background()
}
// BackgroundSecondaryColor returns the secondary background color from the current theme
-func BackgroundSecondaryColor() lipgloss.AdaptiveColor {
+func BackgroundSecondaryColor() color.Color {
return theme.CurrentTheme().BackgroundSecondary()
}
// BackgroundDarkerColor returns the darker background color from the current theme
-func BackgroundDarkerColor() lipgloss.AdaptiveColor {
+func BackgroundDarkerColor() color.Color {
return theme.CurrentTheme().BackgroundDarker()
}
// BorderNormalColor returns the normal border color from the current theme
-func BorderNormalColor() lipgloss.AdaptiveColor {
+func BorderNormalColor() color.Color {
return theme.CurrentTheme().BorderNormal()
}
// BorderFocusedColor returns the focused border color from the current theme
-func BorderFocusedColor() lipgloss.AdaptiveColor {
+func BorderFocusedColor() color.Color {
return theme.CurrentTheme().BorderFocused()
}
// BorderDimColor returns the dim border color from the current theme
-func BorderDimColor() lipgloss.AdaptiveColor {
+func BorderDimColor() color.Color {
return theme.CurrentTheme().BorderDim()
}
diff --git a/internal/tui/theme/catppuccin.go b/internal/tui/theme/catppuccin.go
index a843100ab21c0b3b5b035b67a6322ef2ea5239ef..fd4df0657f82538b57a8eabcb73553df9f9ce9e1 100644
--- a/internal/tui/theme/catppuccin.go
+++ b/internal/tui/theme/catppuccin.go
@@ -2,7 +2,7 @@ package theme
import (
catppuccin "github.com/catppuccin/go"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
)
// CatppuccinTheme implements the Theme interface with Catppuccin colors.
@@ -11,238 +11,162 @@ type CatppuccinTheme struct {
BaseTheme
}
-// NewCatppuccinTheme creates a new instance of the Catppuccin theme.
-func NewCatppuccinTheme() *CatppuccinTheme {
- // Get the Catppuccin palettes
+// NewCatppuccinMochaTheme creates a new instance of the Catppuccin Mocha theme.
+func NewCatppuccinMochaTheme() *CatppuccinTheme {
+ // Get the Catppuccin palette
mocha := catppuccin.Mocha
+
+ theme := &CatppuccinTheme{}
+
+ // Base colors
+ theme.PrimaryColor = lipgloss.Color(mocha.Blue().Hex)
+ theme.SecondaryColor = lipgloss.Color(mocha.Mauve().Hex)
+ theme.AccentColor = lipgloss.Color(mocha.Peach().Hex)
+
+ // Status colors
+ theme.ErrorColor = lipgloss.Color(mocha.Red().Hex)
+ theme.WarningColor = lipgloss.Color(mocha.Peach().Hex)
+ theme.SuccessColor = lipgloss.Color(mocha.Green().Hex)
+ theme.InfoColor = lipgloss.Color(mocha.Blue().Hex)
+
+ // Text colors
+ theme.TextColor = lipgloss.Color(mocha.Text().Hex)
+ theme.TextMutedColor = lipgloss.Color(mocha.Subtext0().Hex)
+ theme.TextEmphasizedColor = lipgloss.Color(mocha.Lavender().Hex)
+
+ // Background colors
+ theme.BackgroundColor = lipgloss.Color("#212121") // From existing styles
+ theme.BackgroundSecondaryColor = lipgloss.Color("#2c2c2c") // From existing styles
+ theme.BackgroundDarkerColor = lipgloss.Color("#181818") // From existing styles
+
+ // Border colors
+ theme.BorderNormalColor = lipgloss.Color("#4b4c5c") // From existing styles
+ theme.BorderFocusedColor = lipgloss.Color(mocha.Blue().Hex)
+ theme.BorderDimColor = lipgloss.Color(mocha.Surface0().Hex)
+
+ // Diff view colors
+ theme.DiffAddedColor = lipgloss.Color("#478247") // From existing diff.go
+ theme.DiffRemovedColor = lipgloss.Color("#7C4444") // From existing diff.go
+ theme.DiffContextColor = lipgloss.Color("#a0a0a0") // From existing diff.go
+ theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0") // From existing diff.go
+ theme.DiffHighlightAddedColor = lipgloss.Color("#DAFADA") // From existing diff.go
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#FADADD") // From existing diff.go
+ theme.DiffAddedBgColor = lipgloss.Color("#303A30") // From existing diff.go
+ theme.DiffRemovedBgColor = lipgloss.Color("#3A3030") // From existing diff.go
+ theme.DiffContextBgColor = lipgloss.Color("#212121") // From existing diff.go
+ theme.DiffLineNumberColor = lipgloss.Color("#888888") // From existing diff.go
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#293229") // From existing diff.go
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#332929") // From existing diff.go
+
+ // Markdown colors
+ theme.MarkdownTextColor = lipgloss.Color(mocha.Text().Hex)
+ theme.MarkdownHeadingColor = lipgloss.Color(mocha.Mauve().Hex)
+ theme.MarkdownLinkColor = lipgloss.Color(mocha.Sky().Hex)
+ theme.MarkdownLinkTextColor = lipgloss.Color(mocha.Pink().Hex)
+ theme.MarkdownCodeColor = lipgloss.Color(mocha.Green().Hex)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(mocha.Yellow().Hex)
+ theme.MarkdownEmphColor = lipgloss.Color(mocha.Yellow().Hex)
+ theme.MarkdownStrongColor = lipgloss.Color(mocha.Peach().Hex)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(mocha.Overlay0().Hex)
+ theme.MarkdownListItemColor = lipgloss.Color(mocha.Blue().Hex)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(mocha.Sky().Hex)
+ theme.MarkdownImageColor = lipgloss.Color(mocha.Sapphire().Hex)
+ theme.MarkdownImageTextColor = lipgloss.Color(mocha.Pink().Hex)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(mocha.Text().Hex)
+
+ // Syntax highlighting colors
+ theme.SyntaxCommentColor = lipgloss.Color(mocha.Overlay1().Hex)
+ theme.SyntaxKeywordColor = lipgloss.Color(mocha.Pink().Hex)
+ theme.SyntaxFunctionColor = lipgloss.Color(mocha.Green().Hex)
+ theme.SyntaxVariableColor = lipgloss.Color(mocha.Sky().Hex)
+ theme.SyntaxStringColor = lipgloss.Color(mocha.Yellow().Hex)
+ theme.SyntaxNumberColor = lipgloss.Color(mocha.Teal().Hex)
+ theme.SyntaxTypeColor = lipgloss.Color(mocha.Sky().Hex)
+ theme.SyntaxOperatorColor = lipgloss.Color(mocha.Pink().Hex)
+ theme.SyntaxPunctuationColor = lipgloss.Color(mocha.Text().Hex)
+
+ return theme
+}
+
+// NewCatppuccinLatteTheme creates a new instance of the Catppuccin Latte theme.
+func NewCatppuccinLatteTheme() *CatppuccinTheme {
+ // Get the Catppuccin palette
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,
- }
+ theme.PrimaryColor = lipgloss.Color(latte.Blue().Hex)
+ theme.SecondaryColor = lipgloss.Color(latte.Mauve().Hex)
+ theme.AccentColor = lipgloss.Color(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,
- }
+ theme.ErrorColor = lipgloss.Color(latte.Red().Hex)
+ theme.WarningColor = lipgloss.Color(latte.Peach().Hex)
+ theme.SuccessColor = lipgloss.Color(latte.Green().Hex)
+ theme.InfoColor = lipgloss.Color(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,
- }
+ theme.TextColor = lipgloss.Color(latte.Text().Hex)
+ theme.TextMutedColor = lipgloss.Color(latte.Subtext0().Hex)
+ theme.TextEmphasizedColor = lipgloss.Color(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
- }
+ theme.BackgroundColor = lipgloss.Color("#EEEEEE") // Light equivalent
+ theme.BackgroundSecondaryColor = lipgloss.Color("#E0E0E0") // Light equivalent
+ theme.BackgroundDarkerColor = lipgloss.Color("#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,
- }
+ theme.BorderNormalColor = lipgloss.Color("#BDBDBD") // Light equivalent
+ theme.BorderFocusedColor = lipgloss.Color(latte.Blue().Hex)
+ theme.BorderDimColor = lipgloss.Color(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
- }
+ theme.DiffAddedColor = lipgloss.Color("#2E7D32") // Light equivalent
+ theme.DiffRemovedColor = lipgloss.Color("#C62828") // Light equivalent
+ theme.DiffContextColor = lipgloss.Color("#757575") // Light equivalent
+ theme.DiffHunkHeaderColor = lipgloss.Color("#757575") // Light equivalent
+ theme.DiffHighlightAddedColor = lipgloss.Color("#A5D6A7") // Light equivalent
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#EF9A9A") // Light equivalent
+ theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9") // Light equivalent
+ theme.DiffRemovedBgColor = lipgloss.Color("#FFEBEE") // Light equivalent
+ theme.DiffContextBgColor = lipgloss.Color("#F5F5F5") // Light equivalent
+ theme.DiffLineNumberColor = lipgloss.Color("#9E9E9E") // Light equivalent
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9") // Light equivalent
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#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,
- }
+ theme.MarkdownTextColor = lipgloss.Color(latte.Text().Hex)
+ theme.MarkdownHeadingColor = lipgloss.Color(latte.Mauve().Hex)
+ theme.MarkdownLinkColor = lipgloss.Color(latte.Sky().Hex)
+ theme.MarkdownLinkTextColor = lipgloss.Color(latte.Pink().Hex)
+ theme.MarkdownCodeColor = lipgloss.Color(latte.Green().Hex)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(latte.Yellow().Hex)
+ theme.MarkdownEmphColor = lipgloss.Color(latte.Yellow().Hex)
+ theme.MarkdownStrongColor = lipgloss.Color(latte.Peach().Hex)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(latte.Overlay0().Hex)
+ theme.MarkdownListItemColor = lipgloss.Color(latte.Blue().Hex)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(latte.Sky().Hex)
+ theme.MarkdownImageColor = lipgloss.Color(latte.Sapphire().Hex)
+ theme.MarkdownImageTextColor = lipgloss.Color(latte.Pink().Hex)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(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,
- }
+ theme.SyntaxCommentColor = lipgloss.Color(latte.Overlay1().Hex)
+ theme.SyntaxKeywordColor = lipgloss.Color(latte.Pink().Hex)
+ theme.SyntaxFunctionColor = lipgloss.Color(latte.Green().Hex)
+ theme.SyntaxVariableColor = lipgloss.Color(latte.Sky().Hex)
+ theme.SyntaxStringColor = lipgloss.Color(latte.Yellow().Hex)
+ theme.SyntaxNumberColor = lipgloss.Color(latte.Teal().Hex)
+ theme.SyntaxTypeColor = lipgloss.Color(latte.Sky().Hex)
+ theme.SyntaxOperatorColor = lipgloss.Color(latte.Pink().Hex)
+ theme.SyntaxPunctuationColor = lipgloss.Color(latte.Text().Hex)
return theme
}
func init() {
- // Register the Catppuccin theme with the theme manager
- RegisterTheme("catppuccin", NewCatppuccinTheme())
-}
\ No newline at end of file
+ // Register the Catppuccin themes with the theme manager
+ RegisterTheme("catppuccin-mocha", NewCatppuccinMochaTheme())
+ RegisterTheme("catppuccin-latte", NewCatppuccinLatteTheme())
+}
diff --git a/internal/tui/theme/dracula.go b/internal/tui/theme/dracula.go
index e625206ae5e0470081244fa306443bbdd81a0b93..eaf981c786e20f605bdeb7ac90c1e3f955421f59 100644
--- a/internal/tui/theme/dracula.go
+++ b/internal/tui/theme/dracula.go
@@ -1,7 +1,7 @@
package theme
import (
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
)
// DraculaTheme implements the Theme interface with Dracula colors.
@@ -28,242 +28,74 @@ func NewDraculaTheme() *DraculaTheme {
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,
- }
+ theme.PrimaryColor = lipgloss.Color(darkPurple)
+ theme.SecondaryColor = lipgloss.Color(darkPink)
+ theme.AccentColor = lipgloss.Color(darkCyan)
// 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,
- }
+ theme.ErrorColor = lipgloss.Color(darkRed)
+ theme.WarningColor = lipgloss.Color(darkOrange)
+ theme.SuccessColor = lipgloss.Color(darkGreen)
+ theme.InfoColor = lipgloss.Color(darkCyan)
// 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,
- }
+ theme.TextColor = lipgloss.Color(darkForeground)
+ theme.TextMutedColor = lipgloss.Color(darkComment)
+ theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
// 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
- }
+ theme.BackgroundColor = lipgloss.Color(darkBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#21222c") // Slightly darker 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,
- }
+ theme.BorderNormalColor = lipgloss.Color(darkBorder)
+ theme.BorderFocusedColor = lipgloss.Color(darkPurple)
+ theme.BorderDimColor = lipgloss.Color(darkSelection)
// 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",
- }
+ theme.DiffAddedColor = lipgloss.Color(darkGreen)
+ theme.DiffRemovedColor = lipgloss.Color(darkRed)
+ theme.DiffContextColor = lipgloss.Color(darkComment)
+ theme.DiffHunkHeaderColor = lipgloss.Color(darkPurple)
+ theme.DiffHighlightAddedColor = lipgloss.Color("#50fa7b")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#ff5555")
+ theme.DiffAddedBgColor = lipgloss.Color("#2c3b2c")
+ theme.DiffRemovedBgColor = lipgloss.Color("#3b2c2c")
+ theme.DiffContextBgColor = lipgloss.Color(darkBackground)
+ theme.DiffLineNumberColor = lipgloss.Color(darkComment)
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#253025")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#302525")
// 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,
- }
+ theme.MarkdownTextColor = lipgloss.Color(darkForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(darkPink)
+ theme.MarkdownLinkColor = lipgloss.Color(darkPurple)
+ theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
+ theme.MarkdownListItemColor = lipgloss.Color(darkPurple)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
+ theme.MarkdownImageColor = lipgloss.Color(darkPurple)
+ theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
// 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,
- }
+ theme.SyntaxCommentColor = lipgloss.Color(darkComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(darkPink)
+ theme.SyntaxFunctionColor = lipgloss.Color(darkGreen)
+ theme.SyntaxVariableColor = lipgloss.Color(darkOrange)
+ theme.SyntaxStringColor = lipgloss.Color(darkYellow)
+ theme.SyntaxNumberColor = lipgloss.Color(darkPurple)
+ theme.SyntaxTypeColor = lipgloss.Color(darkCyan)
+ theme.SyntaxOperatorColor = lipgloss.Color(darkPink)
+ theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
return theme
}
diff --git a/internal/tui/theme/flexoki.go b/internal/tui/theme/flexoki.go
index 49d94beb15656775d7f79679391aa6589fb18473..fc7f59b81ee056ef0ef771b2132f181388036a4a 100644
--- a/internal/tui/theme/flexoki.go
+++ b/internal/tui/theme/flexoki.go
@@ -1,7 +1,7 @@
package theme
import (
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
)
// Flexoki color palette constants
@@ -49,234 +49,156 @@ type FlexokiTheme struct {
BaseTheme
}
-// NewFlexokiTheme creates a new instance of the Flexoki theme.
-func NewFlexokiTheme() *FlexokiTheme {
+// NewFlexokiDarkTheme creates a new instance of the Flexoki Dark theme.
+func NewFlexokiDarkTheme() *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,
- }
+ theme.PrimaryColor = lipgloss.Color(flexokiBlue400)
+ theme.SecondaryColor = lipgloss.Color(flexokiPurple400)
+ theme.AccentColor = lipgloss.Color(flexokiOrange400)
// 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,
- }
+ theme.ErrorColor = lipgloss.Color(flexokiRed400)
+ theme.WarningColor = lipgloss.Color(flexokiYellow400)
+ theme.SuccessColor = lipgloss.Color(flexokiGreen400)
+ theme.InfoColor = lipgloss.Color(flexokiCyan400)
// 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,
- }
+ theme.TextColor = lipgloss.Color(flexokiBase300)
+ theme.TextMutedColor = lipgloss.Color(flexokiBase700)
+ theme.TextEmphasizedColor = lipgloss.Color(flexokiYellow400)
// 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,
- }
+ theme.BackgroundColor = lipgloss.Color(flexokiBlack)
+ theme.BackgroundSecondaryColor = lipgloss.Color(flexokiBase950)
+ theme.BackgroundDarkerColor = lipgloss.Color(flexokiBase900)
// 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,
- }
+ theme.BorderNormalColor = lipgloss.Color(flexokiBase900)
+ theme.BorderFocusedColor = lipgloss.Color(flexokiBlue400)
+ theme.BorderDimColor = lipgloss.Color(flexokiBase850)
// 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
- }
+ theme.DiffAddedColor = lipgloss.Color(flexokiGreen400)
+ theme.DiffRemovedColor = lipgloss.Color(flexokiRed400)
+ theme.DiffContextColor = lipgloss.Color(flexokiBase700)
+ theme.DiffHunkHeaderColor = lipgloss.Color(flexokiBase700)
+ theme.DiffHighlightAddedColor = lipgloss.Color(flexokiGreen400)
+ theme.DiffHighlightRemovedColor = lipgloss.Color(flexokiRed400)
+ theme.DiffAddedBgColor = lipgloss.Color("#1D2419") // Darker green background
+ theme.DiffRemovedBgColor = lipgloss.Color("#241919") // Darker red background
+ theme.DiffContextBgColor = lipgloss.Color(flexokiBlack)
+ theme.DiffLineNumberColor = lipgloss.Color(flexokiBase700)
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#1A2017") // Slightly darker green
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#201717") // Slightly darker 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,
- }
+ theme.MarkdownTextColor = lipgloss.Color(flexokiBase300)
+ theme.MarkdownHeadingColor = lipgloss.Color(flexokiYellow400)
+ theme.MarkdownLinkColor = lipgloss.Color(flexokiCyan400)
+ theme.MarkdownLinkTextColor = lipgloss.Color(flexokiMagenta400)
+ theme.MarkdownCodeColor = lipgloss.Color(flexokiGreen400)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(flexokiCyan400)
+ theme.MarkdownEmphColor = lipgloss.Color(flexokiYellow400)
+ theme.MarkdownStrongColor = lipgloss.Color(flexokiOrange400)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(flexokiBase800)
+ theme.MarkdownListItemColor = lipgloss.Color(flexokiBlue400)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(flexokiBlue400)
+ theme.MarkdownImageColor = lipgloss.Color(flexokiPurple400)
+ theme.MarkdownImageTextColor = lipgloss.Color(flexokiMagenta400)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(flexokiBase300)
// 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
- }
+ theme.SyntaxCommentColor = lipgloss.Color(flexokiBase700) // tx-3
+ theme.SyntaxKeywordColor = lipgloss.Color(flexokiGreen400) // gr
+ theme.SyntaxFunctionColor = lipgloss.Color(flexokiOrange400) // or
+ theme.SyntaxVariableColor = lipgloss.Color(flexokiBlue400) // bl
+ theme.SyntaxStringColor = lipgloss.Color(flexokiCyan400) // cy
+ theme.SyntaxNumberColor = lipgloss.Color(flexokiPurple400) // pu
+ theme.SyntaxTypeColor = lipgloss.Color(flexokiYellow400) // ye
+ theme.SyntaxOperatorColor = lipgloss.Color(flexokiBase500) // tx-2
+ theme.SyntaxPunctuationColor = lipgloss.Color(flexokiBase500) // tx-2
+
+ return theme
+}
+
+// NewFlexokiLightTheme creates a new instance of the Flexoki Light theme.
+func NewFlexokiLightTheme() *FlexokiTheme {
+ theme := &FlexokiTheme{}
+
+ // Base colors
+ theme.PrimaryColor = lipgloss.Color(flexokiBlue600)
+ theme.SecondaryColor = lipgloss.Color(flexokiPurple600)
+ theme.AccentColor = lipgloss.Color(flexokiOrange600)
+
+ // Status colors
+ theme.ErrorColor = lipgloss.Color(flexokiRed600)
+ theme.WarningColor = lipgloss.Color(flexokiYellow600)
+ theme.SuccessColor = lipgloss.Color(flexokiGreen600)
+ theme.InfoColor = lipgloss.Color(flexokiCyan600)
+
+ // Text colors
+ theme.TextColor = lipgloss.Color(flexokiBase600)
+ theme.TextMutedColor = lipgloss.Color(flexokiBase500)
+ theme.TextEmphasizedColor = lipgloss.Color(flexokiYellow600)
+
+ // Background colors
+ theme.BackgroundColor = lipgloss.Color(flexokiPaper)
+ theme.BackgroundSecondaryColor = lipgloss.Color(flexokiBase50)
+ theme.BackgroundDarkerColor = lipgloss.Color(flexokiBase100)
+
+ // Border colors
+ theme.BorderNormalColor = lipgloss.Color(flexokiBase100)
+ theme.BorderFocusedColor = lipgloss.Color(flexokiBlue600)
+ theme.BorderDimColor = lipgloss.Color(flexokiBase150)
+
+ // Diff view colors
+ theme.DiffAddedColor = lipgloss.Color(flexokiGreen600)
+ theme.DiffRemovedColor = lipgloss.Color(flexokiRed600)
+ theme.DiffContextColor = lipgloss.Color(flexokiBase500)
+ theme.DiffHunkHeaderColor = lipgloss.Color(flexokiBase500)
+ theme.DiffHighlightAddedColor = lipgloss.Color(flexokiGreen600)
+ theme.DiffHighlightRemovedColor = lipgloss.Color(flexokiRed600)
+ theme.DiffAddedBgColor = lipgloss.Color("#EFF2E2") // Light green background
+ theme.DiffRemovedBgColor = lipgloss.Color("#F2E2E2") // Light red background
+ theme.DiffContextBgColor = lipgloss.Color(flexokiPaper)
+ theme.DiffLineNumberColor = lipgloss.Color(flexokiBase500)
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#E5EBD9") // Light green
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#EBD9D9") // Light red
+
+ // Markdown colors
+ theme.MarkdownTextColor = lipgloss.Color(flexokiBase600)
+ theme.MarkdownHeadingColor = lipgloss.Color(flexokiYellow600)
+ theme.MarkdownLinkColor = lipgloss.Color(flexokiCyan600)
+ theme.MarkdownLinkTextColor = lipgloss.Color(flexokiMagenta600)
+ theme.MarkdownCodeColor = lipgloss.Color(flexokiGreen600)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(flexokiCyan600)
+ theme.MarkdownEmphColor = lipgloss.Color(flexokiYellow600)
+ theme.MarkdownStrongColor = lipgloss.Color(flexokiOrange600)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(flexokiBase200)
+ theme.MarkdownListItemColor = lipgloss.Color(flexokiBlue600)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(flexokiBlue600)
+ theme.MarkdownImageColor = lipgloss.Color(flexokiPurple600)
+ theme.MarkdownImageTextColor = lipgloss.Color(flexokiMagenta600)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(flexokiBase600)
+
+ // Syntax highlighting colors (based on Flexoki's mappings)
+ theme.SyntaxCommentColor = lipgloss.Color(flexokiBase300) // tx-3
+ theme.SyntaxKeywordColor = lipgloss.Color(flexokiGreen600) // gr
+ theme.SyntaxFunctionColor = lipgloss.Color(flexokiOrange600) // or
+ theme.SyntaxVariableColor = lipgloss.Color(flexokiBlue600) // bl
+ theme.SyntaxStringColor = lipgloss.Color(flexokiCyan600) // cy
+ theme.SyntaxNumberColor = lipgloss.Color(flexokiPurple600) // pu
+ theme.SyntaxTypeColor = lipgloss.Color(flexokiYellow600) // ye
+ theme.SyntaxOperatorColor = lipgloss.Color(flexokiBase500) // tx-2
+ theme.SyntaxPunctuationColor = lipgloss.Color(flexokiBase500) // tx-2
return theme
}
func init() {
- // Register the Flexoki theme with the theme manager
- RegisterTheme("flexoki", NewFlexokiTheme())
+ // Register the Flexoki themes with the theme manager
+ RegisterTheme("flexoki-dark", NewFlexokiDarkTheme())
+ RegisterTheme("flexoki-light", NewFlexokiLightTheme())
}
\ No newline at end of file
diff --git a/internal/tui/theme/gruvbox.go b/internal/tui/theme/gruvbox.go
index ed544b84de80446bf0f90be40adbc35cd0ba0689..6df6ebb4d5723052d419476f5c9b9397e1bafb3d 100644
--- a/internal/tui/theme/gruvbox.go
+++ b/internal/tui/theme/gruvbox.go
@@ -1,7 +1,7 @@
package theme
import (
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
)
// Gruvbox color palette constants
@@ -74,229 +74,151 @@ 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,
- }
+ theme.PrimaryColor = lipgloss.Color(gruvboxDarkBlueBright)
+ theme.SecondaryColor = lipgloss.Color(gruvboxDarkPurpleBright)
+ theme.AccentColor = lipgloss.Color(gruvboxDarkOrangeBright)
// 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,
- }
+ theme.ErrorColor = lipgloss.Color(gruvboxDarkRedBright)
+ theme.WarningColor = lipgloss.Color(gruvboxDarkYellowBright)
+ theme.SuccessColor = lipgloss.Color(gruvboxDarkGreenBright)
+ theme.InfoColor = lipgloss.Color(gruvboxDarkBlueBright)
// 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,
- }
+ theme.TextColor = lipgloss.Color(gruvboxDarkFg1)
+ theme.TextMutedColor = lipgloss.Color(gruvboxDarkFg4)
+ theme.TextEmphasizedColor = lipgloss.Color(gruvboxDarkYellowBright)
// 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,
- }
+ theme.BackgroundColor = lipgloss.Color(gruvboxDarkBg0)
+ theme.BackgroundSecondaryColor = lipgloss.Color(gruvboxDarkBg1)
+ theme.BackgroundDarkerColor = lipgloss.Color(gruvboxDarkBg0Soft)
// 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,
- }
+ theme.BorderNormalColor = lipgloss.Color(gruvboxDarkBg2)
+ theme.BorderFocusedColor = lipgloss.Color(gruvboxDarkBlueBright)
+ theme.BorderDimColor = lipgloss.Color(gruvboxDarkBg1)
// 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
- }
+ theme.DiffAddedColor = lipgloss.Color(gruvboxDarkGreenBright)
+ theme.DiffRemovedColor = lipgloss.Color(gruvboxDarkRedBright)
+ theme.DiffContextColor = lipgloss.Color(gruvboxDarkFg4)
+ theme.DiffHunkHeaderColor = lipgloss.Color(gruvboxDarkFg3)
+ theme.DiffHighlightAddedColor = lipgloss.Color(gruvboxDarkGreenBright)
+ theme.DiffHighlightRemovedColor = lipgloss.Color(gruvboxDarkRedBright)
+ theme.DiffAddedBgColor = lipgloss.Color("#3C4C3C") // Darker green background
+ theme.DiffRemovedBgColor = lipgloss.Color("#4C3C3C") // Darker red background
+ theme.DiffContextBgColor = lipgloss.Color(gruvboxDarkBg0)
+ theme.DiffLineNumberColor = lipgloss.Color(gruvboxDarkFg4)
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#32432F") // Slightly darker green
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#43322F") // Slightly darker 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,
- }
+ theme.MarkdownTextColor = lipgloss.Color(gruvboxDarkFg1)
+ theme.MarkdownHeadingColor = lipgloss.Color(gruvboxDarkYellowBright)
+ theme.MarkdownLinkColor = lipgloss.Color(gruvboxDarkBlueBright)
+ theme.MarkdownLinkTextColor = lipgloss.Color(gruvboxDarkAquaBright)
+ theme.MarkdownCodeColor = lipgloss.Color(gruvboxDarkGreenBright)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(gruvboxDarkAquaBright)
+ theme.MarkdownEmphColor = lipgloss.Color(gruvboxDarkYellowBright)
+ theme.MarkdownStrongColor = lipgloss.Color(gruvboxDarkOrangeBright)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(gruvboxDarkBg3)
+ theme.MarkdownListItemColor = lipgloss.Color(gruvboxDarkBlueBright)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(gruvboxDarkBlueBright)
+ theme.MarkdownImageColor = lipgloss.Color(gruvboxDarkPurpleBright)
+ theme.MarkdownImageTextColor = lipgloss.Color(gruvboxDarkAquaBright)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(gruvboxDarkFg1)
// 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,
- }
+ theme.SyntaxCommentColor = lipgloss.Color(gruvboxDarkGray)
+ theme.SyntaxKeywordColor = lipgloss.Color(gruvboxDarkRedBright)
+ theme.SyntaxFunctionColor = lipgloss.Color(gruvboxDarkGreenBright)
+ theme.SyntaxVariableColor = lipgloss.Color(gruvboxDarkBlueBright)
+ theme.SyntaxStringColor = lipgloss.Color(gruvboxDarkYellowBright)
+ theme.SyntaxNumberColor = lipgloss.Color(gruvboxDarkPurpleBright)
+ theme.SyntaxTypeColor = lipgloss.Color(gruvboxDarkYellow)
+ theme.SyntaxOperatorColor = lipgloss.Color(gruvboxDarkAquaBright)
+ theme.SyntaxPunctuationColor = lipgloss.Color(gruvboxDarkFg1)
+
+ return theme
+}
+
+// NewGruvboxLightTheme creates a new instance of the Gruvbox Light theme.
+func NewGruvboxLightTheme() *GruvboxTheme {
+ theme := &GruvboxTheme{}
+
+ // Base colors
+ theme.PrimaryColor = lipgloss.Color(gruvboxLightBlueBright)
+ theme.SecondaryColor = lipgloss.Color(gruvboxLightPurpleBright)
+ theme.AccentColor = lipgloss.Color(gruvboxLightOrangeBright)
+
+ // Status colors
+ theme.ErrorColor = lipgloss.Color(gruvboxLightRedBright)
+ theme.WarningColor = lipgloss.Color(gruvboxLightYellowBright)
+ theme.SuccessColor = lipgloss.Color(gruvboxLightGreenBright)
+ theme.InfoColor = lipgloss.Color(gruvboxLightBlueBright)
+
+ // Text colors
+ theme.TextColor = lipgloss.Color(gruvboxLightFg1)
+ theme.TextMutedColor = lipgloss.Color(gruvboxLightFg4)
+ theme.TextEmphasizedColor = lipgloss.Color(gruvboxLightYellowBright)
+
+ // Background colors
+ theme.BackgroundColor = lipgloss.Color(gruvboxLightBg0)
+ theme.BackgroundSecondaryColor = lipgloss.Color(gruvboxLightBg1)
+ theme.BackgroundDarkerColor = lipgloss.Color(gruvboxLightBg0Soft)
+
+ // Border colors
+ theme.BorderNormalColor = lipgloss.Color(gruvboxLightBg2)
+ theme.BorderFocusedColor = lipgloss.Color(gruvboxLightBlueBright)
+ theme.BorderDimColor = lipgloss.Color(gruvboxLightBg1)
+
+ // Diff view colors
+ theme.DiffAddedColor = lipgloss.Color(gruvboxLightGreenBright)
+ theme.DiffRemovedColor = lipgloss.Color(gruvboxLightRedBright)
+ theme.DiffContextColor = lipgloss.Color(gruvboxLightFg4)
+ theme.DiffHunkHeaderColor = lipgloss.Color(gruvboxLightFg3)
+ theme.DiffHighlightAddedColor = lipgloss.Color(gruvboxLightGreenBright)
+ theme.DiffHighlightRemovedColor = lipgloss.Color(gruvboxLightRedBright)
+ theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9") // Light green background
+ theme.DiffRemovedBgColor = lipgloss.Color("#FFEBEE") // Light red background
+ theme.DiffContextBgColor = lipgloss.Color(gruvboxLightBg0)
+ theme.DiffLineNumberColor = lipgloss.Color(gruvboxLightFg4)
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9") // Light green
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#FFCDD2") // Light red
+
+ // Markdown colors
+ theme.MarkdownTextColor = lipgloss.Color(gruvboxLightFg1)
+ theme.MarkdownHeadingColor = lipgloss.Color(gruvboxLightYellowBright)
+ theme.MarkdownLinkColor = lipgloss.Color(gruvboxLightBlueBright)
+ theme.MarkdownLinkTextColor = lipgloss.Color(gruvboxLightAquaBright)
+ theme.MarkdownCodeColor = lipgloss.Color(gruvboxLightGreenBright)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(gruvboxLightAquaBright)
+ theme.MarkdownEmphColor = lipgloss.Color(gruvboxLightYellowBright)
+ theme.MarkdownStrongColor = lipgloss.Color(gruvboxLightOrangeBright)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(gruvboxLightBg3)
+ theme.MarkdownListItemColor = lipgloss.Color(gruvboxLightBlueBright)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(gruvboxLightBlueBright)
+ theme.MarkdownImageColor = lipgloss.Color(gruvboxLightPurpleBright)
+ theme.MarkdownImageTextColor = lipgloss.Color(gruvboxLightAquaBright)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(gruvboxLightFg1)
+
+ // Syntax highlighting colors
+ theme.SyntaxCommentColor = lipgloss.Color(gruvboxLightGray)
+ theme.SyntaxKeywordColor = lipgloss.Color(gruvboxLightRedBright)
+ theme.SyntaxFunctionColor = lipgloss.Color(gruvboxLightGreenBright)
+ theme.SyntaxVariableColor = lipgloss.Color(gruvboxLightBlueBright)
+ theme.SyntaxStringColor = lipgloss.Color(gruvboxLightYellowBright)
+ theme.SyntaxNumberColor = lipgloss.Color(gruvboxLightPurpleBright)
+ theme.SyntaxTypeColor = lipgloss.Color(gruvboxLightYellow)
+ theme.SyntaxOperatorColor = lipgloss.Color(gruvboxLightAquaBright)
+ theme.SyntaxPunctuationColor = lipgloss.Color(gruvboxLightFg1)
return theme
}
func init() {
- // Register the Gruvbox theme with the theme manager
+ // Register the Gruvbox themes with the theme manager
RegisterTheme("gruvbox", NewGruvboxTheme())
+ RegisterTheme("gruvbox-light", NewGruvboxLightTheme())
}
\ No newline at end of file
diff --git a/internal/tui/theme/monokai.go b/internal/tui/theme/monokai.go
index 4695fefa998f0e038442b1bea3074f5a8a808a0e..8b860316dc649310f6b976369c16ded9a932f3d9 100644
--- a/internal/tui/theme/monokai.go
+++ b/internal/tui/theme/monokai.go
@@ -1,7 +1,7 @@
package theme
import (
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
)
// MonokaiProTheme implements the Theme interface with Monokai Pro colors.
@@ -27,6 +27,80 @@ func NewMonokaiProTheme() *MonokaiProTheme {
darkPurple := "#ab9df2"
darkBorder := "#403e41"
+ theme := &MonokaiProTheme{}
+
+ // Base colors
+ theme.PrimaryColor = lipgloss.Color(darkCyan)
+ theme.SecondaryColor = lipgloss.Color(darkPurple)
+ theme.AccentColor = lipgloss.Color(darkOrange)
+
+ // Status colors
+ theme.ErrorColor = lipgloss.Color(darkRed)
+ theme.WarningColor = lipgloss.Color(darkOrange)
+ theme.SuccessColor = lipgloss.Color(darkGreen)
+ theme.InfoColor = lipgloss.Color(darkBlue)
+
+ // Text colors
+ theme.TextColor = lipgloss.Color(darkForeground)
+ theme.TextMutedColor = lipgloss.Color(darkComment)
+ theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
+
+ // Background colors
+ theme.BackgroundColor = lipgloss.Color(darkBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#221f22") // Slightly darker than background
+
+ // Border colors
+ theme.BorderNormalColor = lipgloss.Color(darkBorder)
+ theme.BorderFocusedColor = lipgloss.Color(darkCyan)
+ theme.BorderDimColor = lipgloss.Color(darkSelection)
+
+ // Diff view colors
+ theme.DiffAddedColor = lipgloss.Color("#a9dc76")
+ theme.DiffRemovedColor = lipgloss.Color("#ff6188")
+ theme.DiffContextColor = lipgloss.Color("#a0a0a0")
+ theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0")
+ theme.DiffHighlightAddedColor = lipgloss.Color("#c2e7a9")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#ff8ca6")
+ theme.DiffAddedBgColor = lipgloss.Color("#3a4a35")
+ theme.DiffRemovedBgColor = lipgloss.Color("#4a3439")
+ theme.DiffContextBgColor = lipgloss.Color(darkBackground)
+ theme.DiffLineNumberColor = lipgloss.Color("#888888")
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#2d3a28")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#3d2a2e")
+
+ // Markdown colors
+ theme.MarkdownTextColor = lipgloss.Color(darkForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(darkPurple)
+ theme.MarkdownLinkColor = lipgloss.Color(darkCyan)
+ theme.MarkdownLinkTextColor = lipgloss.Color(darkBlue)
+ theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
+ theme.MarkdownListItemColor = lipgloss.Color(darkCyan)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(darkBlue)
+ theme.MarkdownImageColor = lipgloss.Color(darkCyan)
+ theme.MarkdownImageTextColor = lipgloss.Color(darkBlue)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
+
+ // Syntax highlighting colors
+ theme.SyntaxCommentColor = lipgloss.Color(darkComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(darkRed)
+ theme.SyntaxFunctionColor = lipgloss.Color(darkGreen)
+ theme.SyntaxVariableColor = lipgloss.Color(darkForeground)
+ theme.SyntaxStringColor = lipgloss.Color(darkYellow)
+ theme.SyntaxNumberColor = lipgloss.Color(darkPurple)
+ theme.SyntaxTypeColor = lipgloss.Color(darkBlue)
+ theme.SyntaxOperatorColor = lipgloss.Color(darkCyan)
+ theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
+
+ return theme
+}
+
+// NewMonokaiProLightTheme creates a new instance of the Monokai Pro Light theme.
+func NewMonokaiProLightTheme() *MonokaiProTheme {
// Light mode colors (adapted from dark)
lightBackground := "#fafafa"
lightCurrentLine := "#f0f0f0"
@@ -45,229 +119,77 @@ func NewMonokaiProTheme() *MonokaiProTheme {
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,
- }
+ theme.PrimaryColor = lipgloss.Color(lightCyan)
+ theme.SecondaryColor = lipgloss.Color(lightPurple)
+ theme.AccentColor = lipgloss.Color(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,
- }
+ theme.ErrorColor = lipgloss.Color(lightRed)
+ theme.WarningColor = lipgloss.Color(lightOrange)
+ theme.SuccessColor = lipgloss.Color(lightGreen)
+ theme.InfoColor = lipgloss.Color(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,
- }
+ theme.TextColor = lipgloss.Color(lightForeground)
+ theme.TextMutedColor = lipgloss.Color(lightComment)
+ theme.TextEmphasizedColor = lipgloss.Color(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
- }
+ theme.BackgroundColor = lipgloss.Color(lightBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#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,
- }
+ theme.BorderNormalColor = lipgloss.Color(lightBorder)
+ theme.BorderFocusedColor = lipgloss.Color(lightCyan)
+ theme.BorderDimColor = lipgloss.Color(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",
- }
+ theme.DiffAddedColor = lipgloss.Color("#9bca65")
+ theme.DiffRemovedColor = lipgloss.Color("#f92672")
+ theme.DiffContextColor = lipgloss.Color("#757575")
+ theme.DiffHunkHeaderColor = lipgloss.Color("#757575")
+ theme.DiffHighlightAddedColor = lipgloss.Color("#c5e0b4")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#ffb3c8")
+ theme.DiffAddedBgColor = lipgloss.Color("#e8f5e9")
+ theme.DiffRemovedBgColor = lipgloss.Color("#ffebee")
+ theme.DiffContextBgColor = lipgloss.Color(lightBackground)
+ theme.DiffLineNumberColor = lipgloss.Color("#9e9e9e")
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#c8e6c9")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#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,
- }
+ theme.MarkdownTextColor = lipgloss.Color(lightForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(lightPurple)
+ theme.MarkdownLinkColor = lipgloss.Color(lightCyan)
+ theme.MarkdownLinkTextColor = lipgloss.Color(lightBlue)
+ theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(lightOrange)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
+ theme.MarkdownListItemColor = lipgloss.Color(lightCyan)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(lightBlue)
+ theme.MarkdownImageColor = lipgloss.Color(lightCyan)
+ theme.MarkdownImageTextColor = lipgloss.Color(lightBlue)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(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,
- }
+ theme.SyntaxCommentColor = lipgloss.Color(lightComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(lightRed)
+ theme.SyntaxFunctionColor = lipgloss.Color(lightGreen)
+ theme.SyntaxVariableColor = lipgloss.Color(lightForeground)
+ theme.SyntaxStringColor = lipgloss.Color(lightYellow)
+ theme.SyntaxNumberColor = lipgloss.Color(lightPurple)
+ theme.SyntaxTypeColor = lipgloss.Color(lightBlue)
+ theme.SyntaxOperatorColor = lipgloss.Color(lightCyan)
+ theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
return theme
}
func init() {
- // Register the Monokai Pro theme with the theme manager
+ // Register the Monokai Pro themes with the theme manager
RegisterTheme("monokai", NewMonokaiProTheme())
+ RegisterTheme("monokai-light", NewMonokaiProLightTheme())
}
\ No newline at end of file
diff --git a/internal/tui/theme/onedark.go b/internal/tui/theme/onedark.go
index 2b4dee50dccdd9639e0245c3d4eaaabb798b0cfe..936998d98142e73de4396e3ccbf320061fc2289e 100644
--- a/internal/tui/theme/onedark.go
+++ b/internal/tui/theme/onedark.go
@@ -1,7 +1,7 @@
package theme
import (
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
)
// OneDarkTheme implements the Theme interface with Atom's One Dark colors.
@@ -28,6 +28,80 @@ func NewOneDarkTheme() *OneDarkTheme {
darkPurple := "#c678dd"
darkBorder := "#3b4048"
+ theme := &OneDarkTheme{}
+
+ // Base colors
+ theme.PrimaryColor = lipgloss.Color(darkBlue)
+ theme.SecondaryColor = lipgloss.Color(darkPurple)
+ theme.AccentColor = lipgloss.Color(darkOrange)
+
+ // Status colors
+ theme.ErrorColor = lipgloss.Color(darkRed)
+ theme.WarningColor = lipgloss.Color(darkOrange)
+ theme.SuccessColor = lipgloss.Color(darkGreen)
+ theme.InfoColor = lipgloss.Color(darkBlue)
+
+ // Text colors
+ theme.TextColor = lipgloss.Color(darkForeground)
+ theme.TextMutedColor = lipgloss.Color(darkComment)
+ theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
+
+ // Background colors
+ theme.BackgroundColor = lipgloss.Color(darkBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#21252b") // Slightly darker than background
+
+ // Border colors
+ theme.BorderNormalColor = lipgloss.Color(darkBorder)
+ theme.BorderFocusedColor = lipgloss.Color(darkBlue)
+ theme.BorderDimColor = lipgloss.Color(darkSelection)
+
+ // Diff view colors
+ theme.DiffAddedColor = lipgloss.Color("#478247")
+ theme.DiffRemovedColor = lipgloss.Color("#7C4444")
+ theme.DiffContextColor = lipgloss.Color("#a0a0a0")
+ theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0")
+ theme.DiffHighlightAddedColor = lipgloss.Color("#DAFADA")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#FADADD")
+ theme.DiffAddedBgColor = lipgloss.Color("#303A30")
+ theme.DiffRemovedBgColor = lipgloss.Color("#3A3030")
+ theme.DiffContextBgColor = lipgloss.Color(darkBackground)
+ theme.DiffLineNumberColor = lipgloss.Color("#888888")
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#293229")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#332929")
+
+ // Markdown colors
+ theme.MarkdownTextColor = lipgloss.Color(darkForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(darkPurple)
+ theme.MarkdownLinkColor = lipgloss.Color(darkBlue)
+ theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
+ theme.MarkdownListItemColor = lipgloss.Color(darkBlue)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
+ theme.MarkdownImageColor = lipgloss.Color(darkBlue)
+ theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
+
+ // Syntax highlighting colors
+ theme.SyntaxCommentColor = lipgloss.Color(darkComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(darkPurple)
+ theme.SyntaxFunctionColor = lipgloss.Color(darkBlue)
+ theme.SyntaxVariableColor = lipgloss.Color(darkRed)
+ theme.SyntaxStringColor = lipgloss.Color(darkGreen)
+ theme.SyntaxNumberColor = lipgloss.Color(darkOrange)
+ theme.SyntaxTypeColor = lipgloss.Color(darkYellow)
+ theme.SyntaxOperatorColor = lipgloss.Color(darkCyan)
+ theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
+
+ return theme
+}
+
+// NewOneLightTheme creates a new instance of the One Light theme.
+func NewOneLightTheme() *OneDarkTheme {
// Light mode colors from Atom One Light
lightBackground := "#fafafa"
lightCurrentLine := "#f0f0f0"
@@ -46,229 +120,77 @@ func NewOneDarkTheme() *OneDarkTheme {
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,
- }
+ theme.PrimaryColor = lipgloss.Color(lightBlue)
+ theme.SecondaryColor = lipgloss.Color(lightPurple)
+ theme.AccentColor = lipgloss.Color(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,
- }
+ theme.ErrorColor = lipgloss.Color(lightRed)
+ theme.WarningColor = lipgloss.Color(lightOrange)
+ theme.SuccessColor = lipgloss.Color(lightGreen)
+ theme.InfoColor = lipgloss.Color(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,
- }
+ theme.TextColor = lipgloss.Color(lightForeground)
+ theme.TextMutedColor = lipgloss.Color(lightComment)
+ theme.TextEmphasizedColor = lipgloss.Color(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
- }
+ theme.BackgroundColor = lipgloss.Color(lightBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#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,
- }
+ theme.BorderNormalColor = lipgloss.Color(lightBorder)
+ theme.BorderFocusedColor = lipgloss.Color(lightBlue)
+ theme.BorderDimColor = lipgloss.Color(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",
- }
+ theme.DiffAddedColor = lipgloss.Color("#2E7D32")
+ theme.DiffRemovedColor = lipgloss.Color("#C62828")
+ theme.DiffContextColor = lipgloss.Color("#757575")
+ theme.DiffHunkHeaderColor = lipgloss.Color("#757575")
+ theme.DiffHighlightAddedColor = lipgloss.Color("#A5D6A7")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#EF9A9A")
+ theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9")
+ theme.DiffRemovedBgColor = lipgloss.Color("#FFEBEE")
+ theme.DiffContextBgColor = lipgloss.Color(lightBackground)
+ theme.DiffLineNumberColor = lipgloss.Color("#9E9E9E")
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#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,
- }
+ theme.MarkdownTextColor = lipgloss.Color(lightForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(lightPurple)
+ theme.MarkdownLinkColor = lipgloss.Color(lightBlue)
+ theme.MarkdownLinkTextColor = lipgloss.Color(lightCyan)
+ theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(lightOrange)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
+ theme.MarkdownListItemColor = lipgloss.Color(lightBlue)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(lightCyan)
+ theme.MarkdownImageColor = lipgloss.Color(lightBlue)
+ theme.MarkdownImageTextColor = lipgloss.Color(lightCyan)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(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,
- }
+ theme.SyntaxCommentColor = lipgloss.Color(lightComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(lightPurple)
+ theme.SyntaxFunctionColor = lipgloss.Color(lightBlue)
+ theme.SyntaxVariableColor = lipgloss.Color(lightRed)
+ theme.SyntaxStringColor = lipgloss.Color(lightGreen)
+ theme.SyntaxNumberColor = lipgloss.Color(lightOrange)
+ theme.SyntaxTypeColor = lipgloss.Color(lightYellow)
+ theme.SyntaxOperatorColor = lipgloss.Color(lightCyan)
+ theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
return theme
}
func init() {
- // Register the One Dark theme with the theme manager
+ // Register the One Dark and One Light themes with the theme manager
RegisterTheme("onedark", NewOneDarkTheme())
+ RegisterTheme("onelight", NewOneLightTheme())
}
\ No newline at end of file
diff --git a/internal/tui/theme/opencode.go b/internal/tui/theme/opencode.go
index efec8615437eef1516582b2492833cfa16ffe4d8..e4a3af7b3208e3a1c475b2333043e65c4264b3e6 100644
--- a/internal/tui/theme/opencode.go
+++ b/internal/tui/theme/opencode.go
@@ -1,7 +1,7 @@
package theme
import (
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
)
// OpenCodeTheme implements the Theme interface with OpenCode brand colors.
@@ -10,8 +10,8 @@ type OpenCodeTheme struct {
BaseTheme
}
-// NewOpenCodeTheme creates a new instance of the OpenCode theme.
-func NewOpenCodeTheme() *OpenCodeTheme {
+// NewOpenCodeDarkTheme creates a new instance of the OpenCode Dark theme.
+func NewOpenCodeDarkTheme() *OpenCodeTheme {
// OpenCode color palette
// Dark mode colors
darkBackground := "#212121"
@@ -29,6 +29,80 @@ func NewOpenCodeTheme() *OpenCodeTheme {
darkYellow := "#e5c07b" // Emphasized text
darkBorder := "#4b4c5c" // Border color
+ theme := &OpenCodeTheme{}
+
+ // Base colors
+ theme.PrimaryColor = lipgloss.Color(darkPrimary)
+ theme.SecondaryColor = lipgloss.Color(darkSecondary)
+ theme.AccentColor = lipgloss.Color(darkAccent)
+
+ // Status colors
+ theme.ErrorColor = lipgloss.Color(darkRed)
+ theme.WarningColor = lipgloss.Color(darkOrange)
+ theme.SuccessColor = lipgloss.Color(darkGreen)
+ theme.InfoColor = lipgloss.Color(darkCyan)
+
+ // Text colors
+ theme.TextColor = lipgloss.Color(darkForeground)
+ theme.TextMutedColor = lipgloss.Color(darkComment)
+ theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
+
+ // Background colors
+ theme.BackgroundColor = lipgloss.Color(darkBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#121212") // Slightly darker than background
+
+ // Border colors
+ theme.BorderNormalColor = lipgloss.Color(darkBorder)
+ theme.BorderFocusedColor = lipgloss.Color(darkPrimary)
+ theme.BorderDimColor = lipgloss.Color(darkSelection)
+
+ // Diff view colors
+ theme.DiffAddedColor = lipgloss.Color("#478247")
+ theme.DiffRemovedColor = lipgloss.Color("#7C4444")
+ theme.DiffContextColor = lipgloss.Color("#a0a0a0")
+ theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0")
+ theme.DiffHighlightAddedColor = lipgloss.Color("#DAFADA")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#FADADD")
+ theme.DiffAddedBgColor = lipgloss.Color("#303A30")
+ theme.DiffRemovedBgColor = lipgloss.Color("#3A3030")
+ theme.DiffContextBgColor = lipgloss.Color(darkBackground)
+ theme.DiffLineNumberColor = lipgloss.Color("#888888")
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#293229")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#332929")
+
+ // Markdown colors
+ theme.MarkdownTextColor = lipgloss.Color(darkForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(darkSecondary)
+ theme.MarkdownLinkColor = lipgloss.Color(darkPrimary)
+ theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(darkAccent)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
+ theme.MarkdownListItemColor = lipgloss.Color(darkPrimary)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
+ theme.MarkdownImageColor = lipgloss.Color(darkPrimary)
+ theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
+
+ // Syntax highlighting colors
+ theme.SyntaxCommentColor = lipgloss.Color(darkComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(darkSecondary)
+ theme.SyntaxFunctionColor = lipgloss.Color(darkPrimary)
+ theme.SyntaxVariableColor = lipgloss.Color(darkRed)
+ theme.SyntaxStringColor = lipgloss.Color(darkGreen)
+ theme.SyntaxNumberColor = lipgloss.Color(darkAccent)
+ theme.SyntaxTypeColor = lipgloss.Color(darkYellow)
+ theme.SyntaxOperatorColor = lipgloss.Color(darkCyan)
+ theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
+
+ return theme
+}
+
+// NewOpenCodeLightTheme creates a new instance of the OpenCode Light theme.
+func NewOpenCodeLightTheme() *OpenCodeTheme {
// Light mode colors
lightBackground := "#f8f8f8"
lightCurrentLine := "#f0f0f0"
@@ -48,230 +122,77 @@ func NewOpenCodeTheme() *OpenCodeTheme {
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,
- }
+ theme.PrimaryColor = lipgloss.Color(lightPrimary)
+ theme.SecondaryColor = lipgloss.Color(lightSecondary)
+ theme.AccentColor = lipgloss.Color(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,
- }
+ theme.ErrorColor = lipgloss.Color(lightRed)
+ theme.WarningColor = lipgloss.Color(lightOrange)
+ theme.SuccessColor = lipgloss.Color(lightGreen)
+ theme.InfoColor = lipgloss.Color(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,
- }
+ theme.TextColor = lipgloss.Color(lightForeground)
+ theme.TextMutedColor = lipgloss.Color(lightComment)
+ theme.TextEmphasizedColor = lipgloss.Color(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
- }
+ theme.BackgroundColor = lipgloss.Color(lightBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#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,
- }
+ theme.BorderNormalColor = lipgloss.Color(lightBorder)
+ theme.BorderFocusedColor = lipgloss.Color(lightPrimary)
+ theme.BorderDimColor = lipgloss.Color(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",
- }
+ theme.DiffAddedColor = lipgloss.Color("#2E7D32")
+ theme.DiffRemovedColor = lipgloss.Color("#C62828")
+ theme.DiffContextColor = lipgloss.Color("#757575")
+ theme.DiffHunkHeaderColor = lipgloss.Color("#757575")
+ theme.DiffHighlightAddedColor = lipgloss.Color("#A5D6A7")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#EF9A9A")
+ theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9")
+ theme.DiffRemovedBgColor = lipgloss.Color("#FFEBEE")
+ theme.DiffContextBgColor = lipgloss.Color(lightBackground)
+ theme.DiffLineNumberColor = lipgloss.Color("#9E9E9E")
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#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,
- }
+ theme.MarkdownTextColor = lipgloss.Color(lightForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(lightSecondary)
+ theme.MarkdownLinkColor = lipgloss.Color(lightPrimary)
+ theme.MarkdownLinkTextColor = lipgloss.Color(lightCyan)
+ theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(lightAccent)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
+ theme.MarkdownListItemColor = lipgloss.Color(lightPrimary)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(lightCyan)
+ theme.MarkdownImageColor = lipgloss.Color(lightPrimary)
+ theme.MarkdownImageTextColor = lipgloss.Color(lightCyan)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(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,
- }
+ theme.SyntaxCommentColor = lipgloss.Color(lightComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(lightSecondary)
+ theme.SyntaxFunctionColor = lipgloss.Color(lightPrimary)
+ theme.SyntaxVariableColor = lipgloss.Color(lightRed)
+ theme.SyntaxStringColor = lipgloss.Color(lightGreen)
+ theme.SyntaxNumberColor = lipgloss.Color(lightAccent)
+ theme.SyntaxTypeColor = lipgloss.Color(lightYellow)
+ theme.SyntaxOperatorColor = lipgloss.Color(lightCyan)
+ theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
return theme
}
func init() {
- // Register the OpenCode theme with the theme manager
- RegisterTheme("opencode", NewOpenCodeTheme())
-}
-
+ // Register the OpenCode themes with the theme manager
+ RegisterTheme("opencode-dark", NewOpenCodeDarkTheme())
+ RegisterTheme("opencode-light", NewOpenCodeLightTheme())
+}
\ No newline at end of file
diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go
index 4ee14a07f8f2247fd7129bae8fd373d4531adf98..c2221b5483f3de37c02df01ee71ed8b0e4ac01f8 100644
--- a/internal/tui/theme/theme.go
+++ b/internal/tui/theme/theme.go
@@ -1,208 +1,205 @@
package theme
import (
- "github.com/charmbracelet/lipgloss"
+ "image/color"
)
-// 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
+ Primary() color.Color
+ Secondary() color.Color
+ Accent() color.Color
// Status colors
- Error() lipgloss.AdaptiveColor
- Warning() lipgloss.AdaptiveColor
- Success() lipgloss.AdaptiveColor
- Info() lipgloss.AdaptiveColor
+ Error() color.Color
+ Warning() color.Color
+ Success() color.Color
+ Info() color.Color
// Text colors
- Text() lipgloss.AdaptiveColor
- TextMuted() lipgloss.AdaptiveColor
- TextEmphasized() lipgloss.AdaptiveColor
+ Text() color.Color
+ TextMuted() color.Color
+ TextEmphasized() color.Color
// Background colors
- Background() lipgloss.AdaptiveColor
- BackgroundSecondary() lipgloss.AdaptiveColor
- BackgroundDarker() lipgloss.AdaptiveColor
+ Background() color.Color
+ BackgroundSecondary() color.Color
+ BackgroundDarker() color.Color
// Border colors
- BorderNormal() lipgloss.AdaptiveColor
- BorderFocused() lipgloss.AdaptiveColor
- BorderDim() lipgloss.AdaptiveColor
+ BorderNormal() color.Color
+ BorderFocused() color.Color
+ BorderDim() color.Color
// 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
+ DiffAdded() color.Color
+ DiffRemoved() color.Color
+ DiffContext() color.Color
+ DiffHunkHeader() color.Color
+ DiffHighlightAdded() color.Color
+ DiffHighlightRemoved() color.Color
+ DiffAddedBg() color.Color
+ DiffRemovedBg() color.Color
+ DiffContextBg() color.Color
+ DiffLineNumber() color.Color
+ DiffAddedLineNumberBg() color.Color
+ DiffRemovedLineNumberBg() color.Color
// 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
+ MarkdownText() color.Color
+ MarkdownHeading() color.Color
+ MarkdownLink() color.Color
+ MarkdownLinkText() color.Color
+ MarkdownCode() color.Color
+ MarkdownBlockQuote() color.Color
+ MarkdownEmph() color.Color
+ MarkdownStrong() color.Color
+ MarkdownHorizontalRule() color.Color
+ MarkdownListItem() color.Color
+ MarkdownListEnumeration() color.Color
+ MarkdownImage() color.Color
+ MarkdownImageText() color.Color
+ MarkdownCodeBlock() color.Color
// 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
+ SyntaxComment() color.Color
+ SyntaxKeyword() color.Color
+ SyntaxFunction() color.Color
+ SyntaxVariable() color.Color
+ SyntaxString() color.Color
+ SyntaxNumber() color.Color
+ SyntaxType() color.Color
+ SyntaxOperator() color.Color
+ SyntaxPunctuation() color.Color
}
// 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
+ PrimaryColor color.Color
+ SecondaryColor color.Color
+ AccentColor color.Color
// Status colors
- ErrorColor lipgloss.AdaptiveColor
- WarningColor lipgloss.AdaptiveColor
- SuccessColor lipgloss.AdaptiveColor
- InfoColor lipgloss.AdaptiveColor
+ ErrorColor color.Color
+ WarningColor color.Color
+ SuccessColor color.Color
+ InfoColor color.Color
// Text colors
- TextColor lipgloss.AdaptiveColor
- TextMutedColor lipgloss.AdaptiveColor
- TextEmphasizedColor lipgloss.AdaptiveColor
+ TextColor color.Color
+ TextMutedColor color.Color
+ TextEmphasizedColor color.Color
// Background colors
- BackgroundColor lipgloss.AdaptiveColor
- BackgroundSecondaryColor lipgloss.AdaptiveColor
- BackgroundDarkerColor lipgloss.AdaptiveColor
+ BackgroundColor color.Color
+ BackgroundSecondaryColor color.Color
+ BackgroundDarkerColor color.Color
// Border colors
- BorderNormalColor lipgloss.AdaptiveColor
- BorderFocusedColor lipgloss.AdaptiveColor
- BorderDimColor lipgloss.AdaptiveColor
+ BorderNormalColor color.Color
+ BorderFocusedColor color.Color
+ BorderDimColor color.Color
// 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
+ DiffAddedColor color.Color
+ DiffRemovedColor color.Color
+ DiffContextColor color.Color
+ DiffHunkHeaderColor color.Color
+ DiffHighlightAddedColor color.Color
+ DiffHighlightRemovedColor color.Color
+ DiffAddedBgColor color.Color
+ DiffRemovedBgColor color.Color
+ DiffContextBgColor color.Color
+ DiffLineNumberColor color.Color
+ DiffAddedLineNumberBgColor color.Color
+ DiffRemovedLineNumberBgColor color.Color
// 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
+ MarkdownTextColor color.Color
+ MarkdownHeadingColor color.Color
+ MarkdownLinkColor color.Color
+ MarkdownLinkTextColor color.Color
+ MarkdownCodeColor color.Color
+ MarkdownBlockQuoteColor color.Color
+ MarkdownEmphColor color.Color
+ MarkdownStrongColor color.Color
+ MarkdownHorizontalRuleColor color.Color
+ MarkdownListItemColor color.Color
+ MarkdownListEnumerationColor color.Color
+ MarkdownImageColor color.Color
+ MarkdownImageTextColor color.Color
+ MarkdownCodeBlockColor color.Color
// 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
+ SyntaxCommentColor color.Color
+ SyntaxKeywordColor color.Color
+ SyntaxFunctionColor color.Color
+ SyntaxVariableColor color.Color
+ SyntaxStringColor color.Color
+ SyntaxNumberColor color.Color
+ SyntaxTypeColor color.Color
+ SyntaxOperatorColor color.Color
+ SyntaxPunctuationColor color.Color
}
// 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
+func (t *BaseTheme) Primary() color.Color { return t.PrimaryColor }
+func (t *BaseTheme) Secondary() color.Color { return t.SecondaryColor }
+func (t *BaseTheme) Accent() color.Color { return t.AccentColor }
+
+func (t *BaseTheme) Error() color.Color { return t.ErrorColor }
+func (t *BaseTheme) Warning() color.Color { return t.WarningColor }
+func (t *BaseTheme) Success() color.Color { return t.SuccessColor }
+func (t *BaseTheme) Info() color.Color { return t.InfoColor }
+
+func (t *BaseTheme) Text() color.Color { return t.TextColor }
+func (t *BaseTheme) TextMuted() color.Color { return t.TextMutedColor }
+func (t *BaseTheme) TextEmphasized() color.Color { return t.TextEmphasizedColor }
+
+func (t *BaseTheme) Background() color.Color { return t.BackgroundColor }
+func (t *BaseTheme) BackgroundSecondary() color.Color { return t.BackgroundSecondaryColor }
+func (t *BaseTheme) BackgroundDarker() color.Color { return t.BackgroundDarkerColor }
+
+func (t *BaseTheme) BorderNormal() color.Color { return t.BorderNormalColor }
+func (t *BaseTheme) BorderFocused() color.Color { return t.BorderFocusedColor }
+func (t *BaseTheme) BorderDim() color.Color { return t.BorderDimColor }
+
+func (t *BaseTheme) DiffAdded() color.Color { return t.DiffAddedColor }
+func (t *BaseTheme) DiffRemoved() color.Color { return t.DiffRemovedColor }
+func (t *BaseTheme) DiffContext() color.Color { return t.DiffContextColor }
+func (t *BaseTheme) DiffHunkHeader() color.Color { return t.DiffHunkHeaderColor }
+func (t *BaseTheme) DiffHighlightAdded() color.Color { return t.DiffHighlightAddedColor }
+func (t *BaseTheme) DiffHighlightRemoved() color.Color { return t.DiffHighlightRemovedColor }
+func (t *BaseTheme) DiffAddedBg() color.Color { return t.DiffAddedBgColor }
+func (t *BaseTheme) DiffRemovedBg() color.Color { return t.DiffRemovedBgColor }
+func (t *BaseTheme) DiffContextBg() color.Color { return t.DiffContextBgColor }
+func (t *BaseTheme) DiffLineNumber() color.Color { return t.DiffLineNumberColor }
+func (t *BaseTheme) DiffAddedLineNumberBg() color.Color { return t.DiffAddedLineNumberBgColor }
+func (t *BaseTheme) DiffRemovedLineNumberBg() color.Color { return t.DiffRemovedLineNumberBgColor }
+
+func (t *BaseTheme) MarkdownText() color.Color { return t.MarkdownTextColor }
+func (t *BaseTheme) MarkdownHeading() color.Color { return t.MarkdownHeadingColor }
+func (t *BaseTheme) MarkdownLink() color.Color { return t.MarkdownLinkColor }
+func (t *BaseTheme) MarkdownLinkText() color.Color { return t.MarkdownLinkTextColor }
+func (t *BaseTheme) MarkdownCode() color.Color { return t.MarkdownCodeColor }
+func (t *BaseTheme) MarkdownBlockQuote() color.Color { return t.MarkdownBlockQuoteColor }
+func (t *BaseTheme) MarkdownEmph() color.Color { return t.MarkdownEmphColor }
+func (t *BaseTheme) MarkdownStrong() color.Color { return t.MarkdownStrongColor }
+func (t *BaseTheme) MarkdownHorizontalRule() color.Color { return t.MarkdownHorizontalRuleColor }
+func (t *BaseTheme) MarkdownListItem() color.Color { return t.MarkdownListItemColor }
+func (t *BaseTheme) MarkdownListEnumeration() color.Color { return t.MarkdownListEnumerationColor }
+func (t *BaseTheme) MarkdownImage() color.Color { return t.MarkdownImageColor }
+func (t *BaseTheme) MarkdownImageText() color.Color { return t.MarkdownImageTextColor }
+func (t *BaseTheme) MarkdownCodeBlock() color.Color { return t.MarkdownCodeBlockColor }
+
+func (t *BaseTheme) SyntaxComment() color.Color { return t.SyntaxCommentColor }
+func (t *BaseTheme) SyntaxKeyword() color.Color { return t.SyntaxKeywordColor }
+func (t *BaseTheme) SyntaxFunction() color.Color { return t.SyntaxFunctionColor }
+func (t *BaseTheme) SyntaxVariable() color.Color { return t.SyntaxVariableColor }
+func (t *BaseTheme) SyntaxString() color.Color { return t.SyntaxStringColor }
+func (t *BaseTheme) SyntaxNumber() color.Color { return t.SyntaxNumberColor }
+func (t *BaseTheme) SyntaxType() color.Color { return t.SyntaxTypeColor }
+func (t *BaseTheme) SyntaxOperator() color.Color { return t.SyntaxOperatorColor }
+func (t *BaseTheme) SyntaxPunctuation() color.Color { return t.SyntaxPunctuationColor }
diff --git a/internal/tui/theme/tokyonight.go b/internal/tui/theme/tokyonight.go
index acd9dbf6c0311c9226d1c81e919df30364272b62..61d82140cda3e3cc9d09f4b6d05b8459dca2cbc5 100644
--- a/internal/tui/theme/tokyonight.go
+++ b/internal/tui/theme/tokyonight.go
@@ -1,7 +1,7 @@
package theme
import (
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
)
// TokyoNightTheme implements the Theme interface with Tokyo Night colors.
@@ -28,6 +28,80 @@ func NewTokyoNightTheme() *TokyoNightTheme {
darkPurple := "#c099ff"
darkBorder := "#3b4261"
+ theme := &TokyoNightTheme{}
+
+ // Base colors
+ theme.PrimaryColor = lipgloss.Color(darkBlue)
+ theme.SecondaryColor = lipgloss.Color(darkPurple)
+ theme.AccentColor = lipgloss.Color(darkOrange)
+
+ // Status colors
+ theme.ErrorColor = lipgloss.Color(darkRed)
+ theme.WarningColor = lipgloss.Color(darkOrange)
+ theme.SuccessColor = lipgloss.Color(darkGreen)
+ theme.InfoColor = lipgloss.Color(darkBlue)
+
+ // Text colors
+ theme.TextColor = lipgloss.Color(darkForeground)
+ theme.TextMutedColor = lipgloss.Color(darkComment)
+ theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
+
+ // Background colors
+ theme.BackgroundColor = lipgloss.Color(darkBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#191B29") // Darker background from palette
+
+ // Border colors
+ theme.BorderNormalColor = lipgloss.Color(darkBorder)
+ theme.BorderFocusedColor = lipgloss.Color(darkBlue)
+ theme.BorderDimColor = lipgloss.Color(darkSelection)
+
+ // Diff view colors
+ theme.DiffAddedColor = lipgloss.Color("#4fd6be") // teal from palette
+ theme.DiffRemovedColor = lipgloss.Color("#c53b53") // red1 from palette
+ theme.DiffContextColor = lipgloss.Color("#828bb8") // fg_dark from palette
+ theme.DiffHunkHeaderColor = lipgloss.Color("#828bb8") // fg_dark from palette
+ theme.DiffHighlightAddedColor = lipgloss.Color("#b8db87") // git.add from palette
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#e26a75") // git.delete from palette
+ theme.DiffAddedBgColor = lipgloss.Color("#20303b")
+ theme.DiffRemovedBgColor = lipgloss.Color("#37222c")
+ theme.DiffContextBgColor = lipgloss.Color(darkBackground)
+ theme.DiffLineNumberColor = lipgloss.Color("#545c7e") // dark3 from palette
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#1b2b34")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#2d1f26")
+
+ // Markdown colors
+ theme.MarkdownTextColor = lipgloss.Color(darkForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(darkPurple)
+ theme.MarkdownLinkColor = lipgloss.Color(darkBlue)
+ theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
+ theme.MarkdownListItemColor = lipgloss.Color(darkBlue)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
+ theme.MarkdownImageColor = lipgloss.Color(darkBlue)
+ theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
+
+ // Syntax highlighting colors
+ theme.SyntaxCommentColor = lipgloss.Color(darkComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(darkPurple)
+ theme.SyntaxFunctionColor = lipgloss.Color(darkBlue)
+ theme.SyntaxVariableColor = lipgloss.Color(darkRed)
+ theme.SyntaxStringColor = lipgloss.Color(darkGreen)
+ theme.SyntaxNumberColor = lipgloss.Color(darkOrange)
+ theme.SyntaxTypeColor = lipgloss.Color(darkYellow)
+ theme.SyntaxOperatorColor = lipgloss.Color(darkCyan)
+ theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
+
+ return theme
+}
+
+// NewTokyoNightDayTheme creates a new instance of the Tokyo Night Day theme.
+func NewTokyoNightDayTheme() *TokyoNightTheme {
// Light mode colors (Tokyo Night Day)
lightBackground := "#e1e2e7"
lightCurrentLine := "#d5d6db"
@@ -46,229 +120,77 @@ func NewTokyoNightTheme() *TokyoNightTheme {
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,
- }
+ theme.PrimaryColor = lipgloss.Color(lightBlue)
+ theme.SecondaryColor = lipgloss.Color(lightPurple)
+ theme.AccentColor = lipgloss.Color(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,
- }
+ theme.ErrorColor = lipgloss.Color(lightRed)
+ theme.WarningColor = lipgloss.Color(lightOrange)
+ theme.SuccessColor = lipgloss.Color(lightGreen)
+ theme.InfoColor = lipgloss.Color(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,
- }
+ theme.TextColor = lipgloss.Color(lightForeground)
+ theme.TextMutedColor = lipgloss.Color(lightComment)
+ theme.TextEmphasizedColor = lipgloss.Color(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
- }
+ theme.BackgroundColor = lipgloss.Color(lightBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#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,
- }
+ theme.BorderNormalColor = lipgloss.Color(lightBorder)
+ theme.BorderFocusedColor = lipgloss.Color(lightBlue)
+ theme.BorderDimColor = lipgloss.Color(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",
- }
+ theme.DiffAddedColor = lipgloss.Color("#1e725c")
+ theme.DiffRemovedColor = lipgloss.Color("#c53b53")
+ theme.DiffContextColor = lipgloss.Color("#7086b5")
+ theme.DiffHunkHeaderColor = lipgloss.Color("#7086b5")
+ theme.DiffHighlightAddedColor = lipgloss.Color("#4db380")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#f52a65")
+ theme.DiffAddedBgColor = lipgloss.Color("#d5e5d5")
+ theme.DiffRemovedBgColor = lipgloss.Color("#f7d8db")
+ theme.DiffContextBgColor = lipgloss.Color(lightBackground)
+ theme.DiffLineNumberColor = lipgloss.Color("#848cb5")
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#c5d5c5")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#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,
- }
+ theme.MarkdownTextColor = lipgloss.Color(lightForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(lightPurple)
+ theme.MarkdownLinkColor = lipgloss.Color(lightBlue)
+ theme.MarkdownLinkTextColor = lipgloss.Color(lightCyan)
+ theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(lightOrange)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
+ theme.MarkdownListItemColor = lipgloss.Color(lightBlue)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(lightCyan)
+ theme.MarkdownImageColor = lipgloss.Color(lightBlue)
+ theme.MarkdownImageTextColor = lipgloss.Color(lightCyan)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(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,
- }
+ theme.SyntaxCommentColor = lipgloss.Color(lightComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(lightPurple)
+ theme.SyntaxFunctionColor = lipgloss.Color(lightBlue)
+ theme.SyntaxVariableColor = lipgloss.Color(lightRed)
+ theme.SyntaxStringColor = lipgloss.Color(lightGreen)
+ theme.SyntaxNumberColor = lipgloss.Color(lightOrange)
+ theme.SyntaxTypeColor = lipgloss.Color(lightYellow)
+ theme.SyntaxOperatorColor = lipgloss.Color(lightCyan)
+ theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
return theme
}
func init() {
- // Register the Tokyo Night theme with the theme manager
+ // Register the Tokyo Night themes with the theme manager
RegisterTheme("tokyonight", NewTokyoNightTheme())
+ RegisterTheme("tokyonight-day", NewTokyoNightDayTheme())
}
\ No newline at end of file
diff --git a/internal/tui/theme/tron.go b/internal/tui/theme/tron.go
index 5f1bdfb0d5aa1594c82bfb0e22f506a9b53e171a..9b55dd1e2cf46404dcacd0e7e84f4d0e78dd16ce 100644
--- a/internal/tui/theme/tron.go
+++ b/internal/tui/theme/tron.go
@@ -1,7 +1,7 @@
package theme
import (
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/lipgloss/v2"
)
// TronTheme implements the Theme interface with Tron-inspired colors.
@@ -29,6 +29,80 @@ func NewTronTheme() *TronTheme {
darkGreen := "#00ff8f"
darkBorder := "#1a2633"
+ theme := &TronTheme{}
+
+ // Base colors
+ theme.PrimaryColor = lipgloss.Color(darkCyan)
+ theme.SecondaryColor = lipgloss.Color(darkBlue)
+ theme.AccentColor = lipgloss.Color(darkOrange)
+
+ // Status colors
+ theme.ErrorColor = lipgloss.Color(darkRed)
+ theme.WarningColor = lipgloss.Color(darkOrange)
+ theme.SuccessColor = lipgloss.Color(darkGreen)
+ theme.InfoColor = lipgloss.Color(darkCyan)
+
+ // Text colors
+ theme.TextColor = lipgloss.Color(darkForeground)
+ theme.TextMutedColor = lipgloss.Color(darkComment)
+ theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
+
+ // Background colors
+ theme.BackgroundColor = lipgloss.Color(darkBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#070d14") // Slightly darker than background
+
+ // Border colors
+ theme.BorderNormalColor = lipgloss.Color(darkBorder)
+ theme.BorderFocusedColor = lipgloss.Color(darkCyan)
+ theme.BorderDimColor = lipgloss.Color(darkSelection)
+
+ // Diff view colors
+ theme.DiffAddedColor = lipgloss.Color(darkGreen)
+ theme.DiffRemovedColor = lipgloss.Color(darkRed)
+ theme.DiffContextColor = lipgloss.Color(darkComment)
+ theme.DiffHunkHeaderColor = lipgloss.Color(darkBlue)
+ theme.DiffHighlightAddedColor = lipgloss.Color("#00ff8f")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#ff3333")
+ theme.DiffAddedBgColor = lipgloss.Color("#0a2a1a")
+ theme.DiffRemovedBgColor = lipgloss.Color("#2a0a0a")
+ theme.DiffContextBgColor = lipgloss.Color(darkBackground)
+ theme.DiffLineNumberColor = lipgloss.Color(darkComment)
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#082015")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#200808")
+
+ // Markdown colors
+ theme.MarkdownTextColor = lipgloss.Color(darkForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(darkCyan)
+ theme.MarkdownLinkColor = lipgloss.Color(darkBlue)
+ theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
+ theme.MarkdownListItemColor = lipgloss.Color(darkBlue)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
+ theme.MarkdownImageColor = lipgloss.Color(darkBlue)
+ theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
+
+ // Syntax highlighting colors
+ theme.SyntaxCommentColor = lipgloss.Color(darkComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(darkCyan)
+ theme.SyntaxFunctionColor = lipgloss.Color(darkGreen)
+ theme.SyntaxVariableColor = lipgloss.Color(darkOrange)
+ theme.SyntaxStringColor = lipgloss.Color(darkYellow)
+ theme.SyntaxNumberColor = lipgloss.Color(darkBlue)
+ theme.SyntaxTypeColor = lipgloss.Color(darkPurple)
+ theme.SyntaxOperatorColor = lipgloss.Color(darkPink)
+ theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
+
+ return theme
+}
+
+// NewTronLightTheme creates a new instance of the Tron Light theme.
+func NewTronLightTheme() *TronTheme {
// Light mode approximation
lightBackground := "#f0f8ff"
lightCurrentLine := "#e0f0ff"
@@ -48,229 +122,77 @@ func NewTronTheme() *TronTheme {
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,
- }
+ theme.PrimaryColor = lipgloss.Color(lightCyan)
+ theme.SecondaryColor = lipgloss.Color(lightBlue)
+ theme.AccentColor = lipgloss.Color(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,
- }
+ theme.ErrorColor = lipgloss.Color(lightRed)
+ theme.WarningColor = lipgloss.Color(lightOrange)
+ theme.SuccessColor = lipgloss.Color(lightGreen)
+ theme.InfoColor = lipgloss.Color(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,
- }
+ theme.TextColor = lipgloss.Color(lightForeground)
+ theme.TextMutedColor = lipgloss.Color(lightComment)
+ theme.TextEmphasizedColor = lipgloss.Color(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
- }
+ theme.BackgroundColor = lipgloss.Color(lightBackground)
+ theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
+ theme.BackgroundDarkerColor = lipgloss.Color("#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,
- }
+ theme.BorderNormalColor = lipgloss.Color(lightBorder)
+ theme.BorderFocusedColor = lipgloss.Color(lightCyan)
+ theme.BorderDimColor = lipgloss.Color(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",
- }
+ theme.DiffAddedColor = lipgloss.Color(lightGreen)
+ theme.DiffRemovedColor = lipgloss.Color(lightRed)
+ theme.DiffContextColor = lipgloss.Color(lightComment)
+ theme.DiffHunkHeaderColor = lipgloss.Color(lightBlue)
+ theme.DiffHighlightAddedColor = lipgloss.Color("#a5d6a7")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#ef9a9a")
+ theme.DiffAddedBgColor = lipgloss.Color("#e8f5e9")
+ theme.DiffRemovedBgColor = lipgloss.Color("#ffebee")
+ theme.DiffContextBgColor = lipgloss.Color(lightBackground)
+ theme.DiffLineNumberColor = lipgloss.Color(lightComment)
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#c8e6c9")
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#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,
- }
+ theme.MarkdownTextColor = lipgloss.Color(lightForeground)
+ theme.MarkdownHeadingColor = lipgloss.Color(lightCyan)
+ theme.MarkdownLinkColor = lipgloss.Color(lightBlue)
+ theme.MarkdownLinkTextColor = lipgloss.Color(lightCyan)
+ theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
+ theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
+ theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
+ theme.MarkdownStrongColor = lipgloss.Color(lightOrange)
+ theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
+ theme.MarkdownListItemColor = lipgloss.Color(lightBlue)
+ theme.MarkdownListEnumerationColor = lipgloss.Color(lightCyan)
+ theme.MarkdownImageColor = lipgloss.Color(lightBlue)
+ theme.MarkdownImageTextColor = lipgloss.Color(lightCyan)
+ theme.MarkdownCodeBlockColor = lipgloss.Color(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,
- }
+ theme.SyntaxCommentColor = lipgloss.Color(lightComment)
+ theme.SyntaxKeywordColor = lipgloss.Color(lightCyan)
+ theme.SyntaxFunctionColor = lipgloss.Color(lightGreen)
+ theme.SyntaxVariableColor = lipgloss.Color(lightOrange)
+ theme.SyntaxStringColor = lipgloss.Color(lightYellow)
+ theme.SyntaxNumberColor = lipgloss.Color(lightBlue)
+ theme.SyntaxTypeColor = lipgloss.Color(lightPurple)
+ theme.SyntaxOperatorColor = lipgloss.Color(lightPink)
+ theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
return theme
}
func init() {
- // Register the Tron theme with the theme manager
+ // Register the Tron themes with the theme manager
RegisterTheme("tron", NewTronTheme())
+ RegisterTheme("tron-light", NewTronLightTheme())
}
\ No newline at end of file
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 060b8c79c8572a0508ebcd95a148ff0743bc7009..92c2177ade6b0e604505b222ef299f3f4b8ca94b 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -5,9 +5,9 @@ import (
"fmt"
"strings"
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/agent"
@@ -99,7 +99,7 @@ type appModel struct {
width, height int
currentPage page.PageID
previousPage page.PageID
- pages map[page.PageID]tea.Model
+ pages map[page.PageID]util.Model
loadedPages map[page.PageID]bool
status core.StatusCmp
app *app.App
@@ -143,6 +143,8 @@ type appModel struct {
func (a appModel) Init() tea.Cmd {
var cmds []tea.Cmd
cmd := a.pages[a.currentPage].Init()
+ t := theme.CurrentTheme()
+ cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
a.loadedPages[a.currentPage] = true
cmds = append(cmds, cmd)
cmd = a.status.Init()
@@ -189,7 +191,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s, _ := a.status.Update(msg)
a.status = s.(core.StatusCmp)
- a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
+ updated, cmd := a.pages[a.currentPage].Update(msg)
+ a.pages[a.currentPage] = updated.(util.Model)
cmds = append(cmds, cmd)
prm, permCmd := a.permissions.Update(msg)
@@ -348,9 +351,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
case dialog.ThemeChangedMsg:
- a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
+ updated, cmd := a.pages[a.currentPage].Update(msg)
+ a.pages[a.currentPage] = updated.(util.Model)
a.showThemeDialog = false
- return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
+ t := theme.CurrentTheme()
+ return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName), tea.SetBackgroundColor(t.Background()))
case dialog.CloseModelDialogMsg:
a.showModelDialog = false
@@ -427,7 +432,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 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
@@ -658,7 +663,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s, _ := a.status.Update(msg)
a.status = s.(core.StatusCmp)
- a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
+ updated, cmd := a.pages[a.currentPage].Update(msg)
+ a.pages[a.currentPage] = updated.(util.Model)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
@@ -914,7 +920,7 @@ func New(app *app.App) tea.Model {
themeDialog: dialog.NewThemeDialogCmp(),
app: app,
commands: []dialog.Command{},
- pages: map[page.PageID]tea.Model{
+ pages: map[page.PageID]util.Model{
page.ChatPage: page.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
},
diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go
index 2707009b3747c058d3a1625803a4283ea529f4f5..ec658a0e65b649decf31fc4134183c2fa14925f7 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.ViewModel
+}
+
func CmdHandler(msg tea.Msg) tea.Cmd {
return func() tea.Msg {
return msg
From b364394cfeafea7fb03aaf9ee52653d53538c0b6 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Tue, 20 May 2025 14:28:54 +0200
Subject: [PATCH 02/73] initial chat refactor
wip
---
internal/diff/diff.go | 246 +-------
internal/highlight/highlight.go | 227 +++++++
internal/tui/components/chat/chat.go | 1 -
internal/tui/components/chat/list_v2.go | 120 ++++
internal/tui/components/chat/message.go | 69 --
internal/tui/components/chat/message_v2.go | 244 ++++++++
internal/tui/components/chat/sidebar.go | 2 +-
internal/tui/components/chat/tool_message.go | 365 +++++++++++
internal/tui/components/core/list/keys.go | 70 +++
internal/tui/components/core/list/list.go | 625 +++++++++++++++++++
internal/tui/page/chat.go | 2 +-
internal/tui/styles/markdown.go | 4 +-
internal/tui/theme/opencode.go | 7 +-
13 files changed, 1667 insertions(+), 315 deletions(-)
create mode 100644 internal/highlight/highlight.go
create mode 100644 internal/tui/components/chat/list_v2.go
create mode 100644 internal/tui/components/chat/message_v2.go
create mode 100644 internal/tui/components/chat/tool_message.go
create mode 100644 internal/tui/components/core/list/keys.go
create mode 100644 internal/tui/components/core/list/list.go
diff --git a/internal/diff/diff.go b/internal/diff/diff.go
index 6dcafa984cd052102807e8454e7bfe047cf08d5d..9e5e8ae43a31f7df945befca3f505563d0e67919 100644
--- a/internal/diff/diff.go
+++ b/internal/diff/diff.go
@@ -1,22 +1,17 @@
package diff
import (
- "bytes"
"fmt"
"image/color"
- "io"
"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/v2"
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
+ "github.com/opencode-ai/opencode/internal/highlight"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/sergi/go-diff/diffmatchpatch"
)
@@ -322,216 +317,6 @@ 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 color.Color) 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)
-}
-
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)
@@ -539,12 +324,11 @@ func getColor(c color.Color) string {
// highlightLine applies syntax highlighting to a single line
func highlightLine(fileName string, line string, bg color.Color) string {
- var buf bytes.Buffer
- err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
+ 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
@@ -561,18 +345,6 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
// Rendering Functions
// -------------------------------------------------------------------------
-func lipglossToHex(color color.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 color.Color) string {
// Find all ANSI sequences in the content
@@ -614,7 +386,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
@@ -651,15 +423,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)
diff --git a/internal/highlight/highlight.go b/internal/highlight/highlight.go
new file mode 100644
index 0000000000000000000000000000000000000000..98315a152292bd6302dd2e840d450e429abc0ff4
--- /dev/null
+++ b/internal/highlight/highlight.go
@@ -0,0 +1,227 @@
+package highlight
+
+import (
+ "bytes"
+ "fmt"
+ "image/color"
+ "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/opencode-ai/opencode/internal/tui/theme"
+)
+
+func SyntaxHighlight(source, fileName string, bg color.Color) (string, 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("terminal16m")
+ if f == nil {
+ f = formatters.Fallback
+ }
+
+ // Dynamic theme based on current theme values
+ syntaxThemeXml := fmt.Sprintf(`
+
+`,
+ 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
+ }
+
+ 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/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index 2aa7ee5d07f324ca45e80dc4fbab9964f05721bb..f7ccea001e1b03e08f16170c1d86db13729853e4 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -138,4 +138,3 @@ func cwd(width int) string {
Width(width).
Render(cwd)
}
-
diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go
new file mode 100644
index 0000000000000000000000000000000000000000..10010ab8b635f7ecfc265163e07d1c87b984e187
--- /dev/null
+++ b/internal/tui/components/chat/list_v2.go
@@ -0,0 +1,120 @@
+package chat
+
+import (
+ "context"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/opencode-ai/opencode/internal/app"
+ "github.com/opencode-ai/opencode/internal/message"
+ "github.com/opencode-ai/opencode/internal/session"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
+ "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"
+)
+
+type MessageListCmp interface {
+ util.Model
+ layout.Sizeable
+}
+
+type messageListCmp struct {
+ app *app.App
+ width, height int
+ session session.Session
+ messages []util.Model
+ listCmp list.ListModel
+}
+
+func NewMessagesListCmp(app *app.App) MessageListCmp {
+ return &messageListCmp{
+ app: app,
+ listCmp: list.New(
+ list.WithGapSize(1),
+ list.WithReverse(true),
+ ),
+ }
+}
+
+func (m *messageListCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case dialog.ThemeChangedMsg:
+ m.listCmp.ResetView()
+ return m, nil
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ cmd := m.SetSession(msg)
+ return m, cmd
+ }
+ return m, nil
+ }
+ return m, nil
+}
+
+func (m *messageListCmp) View() string {
+ return m.listCmp.View()
+}
+
+// GetSize implements MessageListCmp.
+func (m *messageListCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+// SetSize implements MessageListCmp.
+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
+ m.width = width
+ m.height = height
+ return m.listCmp.SetSize(width, height)
+}
+
+func (m *messageListCmp) 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 = make([]util.Model, 0)
+ lastUserMessageTime := messages[0].CreatedAt
+ toolResultMap := make(map[string]message.ToolResult)
+ // first pass to get all tool results
+ for _, msg := range messages {
+ for _, tr := range msg.ToolResults() {
+ toolResultMap[tr.ToolCallID] = tr
+ }
+ }
+ for _, msg := range messages {
+ // TODO: handle tool calls and others here
+ switch msg.Role {
+ case message.User:
+ lastUserMessageTime = msg.CreatedAt
+ m.messages = append(m.messages, NewMessageCmp(WithMessage(msg)))
+ case message.Assistant:
+ // Only add assistant messages if they don't have tool calls or there is some content
+ if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
+ m.messages = append(m.messages, NewMessageCmp(WithMessage(msg), WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
+ }
+ for _, tc := range msg.ToolCalls() {
+ options := []MessageOption{
+ WithToolCall(tc),
+ }
+ if tr, ok := toolResultMap[tc.ID]; ok {
+ options = append(options, WithToolResult(tr))
+ }
+ if msg.FinishPart().Reason == message.FinishReasonCanceled {
+ options = append(options, WithCancelledToolCall(true))
+ }
+ m.messages = append(m.messages, NewMessageCmp(options...))
+ }
+ }
+ }
+ m.listCmp.SetItems(m.messages)
+ return nil
+}
diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go
index f1fdda7265cb1697f10d68cde45c9d0563ecbed3..96a33da9150cd135d006007a0d659217c261f738 100644
--- a/internal/tui/components/chat/message.go
+++ b/internal/tui/components/chat/message.go
@@ -10,7 +10,6 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"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"
@@ -272,66 +271,6 @@ func getToolAction(name string) string {
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 {
@@ -430,14 +369,6 @@ func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
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()
diff --git a/internal/tui/components/chat/message_v2.go b/internal/tui/components/chat/message_v2.go
new file mode 100644
index 0000000000000000000000000000000000000000..1e281ec01b2fe3c72dd1a6faf62da4a685404348
--- /dev/null
+++ b/internal/tui/components/chat/message_v2.go
@@ -0,0 +1,244 @@
+package chat
+
+import (
+ "fmt"
+ "image/color"
+ "path/filepath"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/llm/models"
+
+ "github.com/opencode-ai/opencode/internal/message"
+ "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 MessageCmp interface {
+ util.Model
+ layout.Sizeable
+ layout.Focusable
+}
+
+type messageCmp struct {
+ width int
+ focused bool
+
+ // Used for agent and user messages
+ message message.Message
+ lastUserMessageTime time.Time
+
+ // Used for tool calls
+ toolCall message.ToolCall
+ toolResult message.ToolResult
+ cancelledToolCall bool
+}
+
+type MessageOption func(*messageCmp)
+
+func WithLastUserMessageTime(t time.Time) MessageOption {
+ return func(m *messageCmp) {
+ m.lastUserMessageTime = t
+ }
+}
+
+func WithToolCall(tc message.ToolCall) MessageOption {
+ return func(m *messageCmp) {
+ m.toolCall = tc
+ }
+}
+
+func WithToolResult(tr message.ToolResult) MessageOption {
+ return func(m *messageCmp) {
+ m.toolResult = tr
+ }
+}
+
+func WithMessage(msg message.Message) MessageOption {
+ return func(m *messageCmp) {
+ m.message = msg
+ }
+}
+
+func WithCancelledToolCall(cancelled bool) MessageOption {
+ return func(m *messageCmp) {
+ m.cancelledToolCall = cancelled
+ }
+}
+
+func NewMessageCmp(opts ...MessageOption) MessageCmp {
+ m := &messageCmp{}
+ for _, opt := range opts {
+ opt(m)
+ }
+ return m
+}
+
+func (m *messageCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ return m, nil
+}
+
+func (m *messageCmp) View() string {
+ if m.message.ID != "" {
+ // this is a user or assistant message
+ switch m.message.Role {
+ case message.User:
+ return m.renderUserMessage()
+ default:
+ return m.renderAssistantMessage()
+ }
+ } else if m.toolCall.ID != "" {
+ // this is a tool call message
+ return m.renderToolCallMessage()
+ }
+ return "Unknown Message"
+}
+
+func (m *messageCmp) textWidth() int {
+ if m.toolCall.ID != "" {
+ return m.width - 2 // take into account the border and PaddingLeft
+ }
+ return m.width - 1 // take into account the border
+}
+
+func (msg *messageCmp) style() lipgloss.Style {
+ t := theme.CurrentTheme()
+ var borderColor color.Color
+ borderStyle := lipgloss.NormalBorder()
+ if msg.focused {
+ borderStyle = lipgloss.DoubleBorder()
+ }
+
+ switch msg.message.Role {
+ case message.User:
+ borderColor = t.Secondary()
+ case message.Assistant:
+ borderColor = t.Primary()
+ default:
+ // Tool call
+ borderColor = t.TextMuted()
+ }
+
+ return styles.BaseStyle().
+ BorderLeft(true).
+ Foreground(t.TextMuted()).
+ BorderForeground(borderColor).
+ BorderStyle(borderStyle)
+}
+
+func (m *messageCmp) renderAssistantMessage() string {
+ 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"
+ }
+ parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
+ }
+
+ joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
+ return m.style().Render(joined)
+}
+
+func (m *messageCmp) renderUserMessage() string {
+ t := theme.CurrentTheme()
+ parts := []string{
+ m.markdownContent(),
+ }
+ attachmentStyles := styles.BaseStyle().
+ MarginLeft(1).
+ Background(t.BackgroundSecondary()).
+ Foreground(t.Text())
+ 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().Render(joined)
+}
+
+func (m *messageCmp) toMarkdown(content string) string {
+ r := styles.GetMarkdownRenderer(m.textWidth())
+ rendered, _ := r.Render(content)
+ return strings.TrimSuffix(rendered, "\n")
+}
+
+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)
+}
+
+// Blur implements MessageModel.
+func (m *messageCmp) Blur() tea.Cmd {
+ m.focused = false
+ return nil
+}
+
+// Focus implements MessageModel.
+func (m *messageCmp) Focus() tea.Cmd {
+ m.focused = true
+ return nil
+}
+
+// IsFocused implements MessageModel.
+func (m *messageCmp) IsFocused() bool {
+ return m.focused
+}
+
+func (m *messageCmp) GetSize() (int, int) {
+ return m.width, 0
+}
+
+func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
+ m.width = width
+ return nil
+}
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
index b54769038c508894f0fa289aadcba9e82b3f189f..75e87335d27d83200e3b3ba9c39274bc658a4bc3 100644
--- a/internal/tui/components/chat/sidebar.go
+++ b/internal/tui/components/chat/sidebar.go
@@ -181,7 +181,7 @@ func (m *sidebarCmp) modifiedFiles() string {
Render("Modified Files:")
// If no modified files, show a placeholder message
- if m.modFiles == nil || len(m.modFiles) == 0 {
+ if len(m.modFiles) == 0 {
message := "No modified files"
remainingWidth := m.width - lipgloss.Width(message)
if remainingWidth > 0 {
diff --git a/internal/tui/components/chat/tool_message.go b/internal/tui/components/chat/tool_message.go
new file mode 100644
index 0000000000000000000000000000000000000000..60333c2e6d3ab3fb70c670fa07b573c84b8fa37c
--- /dev/null
+++ b/internal/tui/components/chat/tool_message.go
@@ -0,0 +1,365 @@
+package chat
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss/v2"
+ "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/highlight"
+ "github.com/opencode-ai/opencode/internal/llm/agent"
+ "github.com/opencode-ai/opencode/internal/llm/tools"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/theme"
+)
+
+const responseContextHeight = 10
+
+func (m *messageCmp) renderUnfinishedToolCall() string {
+ toolName := m.toolName()
+ toolAction := m.getToolAction()
+ return fmt.Sprintf("%s: %s", toolName, toolAction)
+}
+
+func (m *messageCmp) renderToolError() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+ err := strings.ReplaceAll(m.toolResult.Content, "\n", " ")
+ err = fmt.Sprintf("Error: %s", err)
+ return baseStyle.Foreground(t.Error()).Render(m.fit(err))
+}
+
+func (m *messageCmp) renderBashTool() string {
+ name := m.toolName()
+ prefix := fmt.Sprintf("%s: ", name)
+ var params tools.BashParams
+ json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
+ command := strings.ReplaceAll(params.Command, "\n", " ")
+ header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), command)
+
+ if result, ok := m.toolResultErrorOrMissing(header); ok {
+ return result
+ }
+ return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
+}
+
+func (m *messageCmp) renderViewTool() string {
+ name := m.toolName()
+ prefix := fmt.Sprintf("%s: ", name)
+ var params tools.ViewParams
+ json.Unmarshal([]byte(m.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))
+ }
+ header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), toolParams...)
+
+ if result, ok := m.toolResultErrorOrMissing(header); ok {
+ return result
+ }
+
+ metadata := tools.ViewResponseMetadata{}
+ json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
+
+ return m.renderTool(header, m.renderCodeContent(metadata.FilePath, metadata.Content, params.Offset))
+}
+
+func (m *messageCmp) renderCodeContent(path, content string, offset int) string {
+ t := theme.CurrentTheme()
+ originalHeight := lipgloss.Height(content)
+ fileContent := truncateHeight(content, responseContextHeight)
+
+ highlighted, _ := highlight.SyntaxHighlight(fileContent, path, t.BackgroundSecondary())
+
+ lines := strings.Split(highlighted, "\n")
+
+ if originalHeight > responseContextHeight {
+ lines = append(lines,
+ lipgloss.NewStyle().Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(
+ fmt.Sprintf("... (%d lines)", originalHeight-responseContextHeight),
+ ),
+ )
+ }
+ for i, line := range lines {
+ lineNumber := lipgloss.NewStyle().
+ PaddingLeft(4).
+ PaddingRight(2).
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(fmt.Sprintf("%d", i+1+offset))
+ formattedLine := lipgloss.NewStyle().
+ Width(m.textWidth() - lipgloss.Width(lineNumber)).
+ Background(t.BackgroundSecondary()).Render(line)
+ lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, lineNumber, formattedLine)
+ }
+ return lipgloss.NewStyle().Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ lines...,
+ ),
+ )
+}
+
+func (m *messageCmp) renderPlainContent(content string) string {
+ t := theme.CurrentTheme()
+ content = strings.TrimSuffix(content, "\n")
+ content = strings.TrimPrefix(content, "\n")
+ lines := strings.Split(fmt.Sprintf("\n%s\n", content), "\n")
+
+ for i, line := range lines {
+ line = " " + line // add padding
+ if len(line) > m.textWidth() {
+ line = m.fit(line)
+ }
+ lines[i] = lipgloss.NewStyle().
+ Width(m.textWidth()).
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(line)
+ }
+ if len(lines) > responseContextHeight {
+ lines = lines[:responseContextHeight]
+ lines = append(lines,
+ lipgloss.NewStyle().Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(
+ fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight),
+ ),
+ )
+ }
+ return strings.Join(lines, "\n")
+}
+
+func (m *messageCmp) renderGenericTool() string {
+ // Tool params
+ name := m.toolName()
+ prefix := fmt.Sprintf("%s: ", name)
+ input := strings.ReplaceAll(m.toolCall.Input, "\n", " ")
+ params := renderParams(m.textWidth()-lipgloss.Width(prefix), input)
+ header := prefix + params
+
+ if result, ok := m.toolResultErrorOrMissing(header); ok {
+ return result
+ }
+ return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
+}
+
+func (m *messageCmp) renderEditTool() string {
+ // Tool params
+ name := m.toolName()
+ prefix := fmt.Sprintf("%s: ", name)
+ var params tools.EditParams
+ json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
+ filePath := removeWorkingDirPrefix(params.FilePath)
+ header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
+
+ if result, ok := m.toolResultErrorOrMissing(header); ok {
+ return result
+ }
+ metadata := tools.EditResponseMetadata{}
+ json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
+ truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
+ formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(m.textWidth()))
+ return m.renderTool(header, formattedDiff)
+}
+
+func (m *messageCmp) renderWriteTool() string {
+ // Tool params
+ name := m.toolName()
+ prefix := fmt.Sprintf("%s: ", name)
+ var params tools.WriteParams
+ json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
+ filePath := removeWorkingDirPrefix(params.FilePath)
+ header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
+ if result, ok := m.toolResultErrorOrMissing(header); ok {
+ return result
+ }
+ return m.renderTool(header, m.renderCodeContent(filePath, params.Content, 0))
+}
+
+func (m *messageCmp) renderToolCallMessage() string {
+ if !m.toolCall.Finished && !m.cancelledToolCall {
+ return m.renderUnfinishedToolCall()
+ }
+ content := ""
+ switch m.toolCall.Name {
+ case tools.ViewToolName:
+ content = m.renderViewTool()
+ case tools.BashToolName:
+ content = m.renderBashTool()
+ case tools.EditToolName:
+ content = m.renderEditTool()
+ case tools.WriteToolName:
+ content = m.renderWriteTool()
+ default:
+ content = m.renderGenericTool()
+ }
+ return m.style().PaddingLeft(1).Render(content)
+}
+
+func (m *messageCmp) toolResultErrorOrMissing(header string) (string, bool) {
+ result := "Waiting for tool to finish..."
+ if m.toolResult.IsError {
+ result = m.renderToolError()
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ result,
+ ), true
+ } else if m.cancelledToolCall {
+ result = "Cancelled"
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ result,
+ ), true
+ } else if m.toolResult.ToolCallID == "" {
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ result,
+ ), true
+ }
+
+ return "", false
+}
+
+func (m *messageCmp) renderTool(header, result string) string {
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ "",
+ result,
+ "",
+ )
+}
+
+func removeWorkingDirPrefix(path string) string {
+ wd := config.WorkingDirectory()
+ path = strings.TrimPrefix(path, wd)
+ return path
+}
+
+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 (m *messageCmp) fit(content string) string {
+ return ansi.Truncate(content, m.textWidth(), "...")
+}
+
+func (m *messageCmp) toolName() string {
+ switch m.toolCall.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 m.toolCall.Name
+ }
+}
+
+func (m *messageCmp) getToolAction() string {
+ switch m.toolCall.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..."
+ default:
+ 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) - 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, "...")
+}
diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..1c26ef26764bb09d1e7219ccc8f7cb4fe29b0b80
--- /dev/null
+++ b/internal/tui/components/core/list/keys.go
@@ -0,0 +1,70 @@
+package list
+
+import "github.com/charmbracelet/bubbles/v2/key"
+
+type KeyMap struct {
+ Down,
+ Up,
+ NDown,
+ NUp,
+ DownOneItem,
+ UpOneItem,
+ HalfPageDown,
+ HalfPageUp,
+ Home,
+ End,
+ Submit 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"),
+ ),
+ DownOneItem: key.NewBinding(
+ key.WithKeys("shift+down"),
+ ),
+ HalfPageDown: key.NewBinding(
+ key.WithKeys("ctrl+d"),
+ ),
+ HalfPageUp: key.NewBinding(
+ key.WithKeys("ctrl+u"),
+ ),
+ Home: key.NewBinding(
+ key.WithKeys("g", "home"),
+ ),
+ End: key.NewBinding(
+ key.WithKeys("shift+g", "end"),
+ ),
+ Submit: key.NewBinding(
+ key.WithKeys("enter", "space"),
+ key.WithHelp("enter/space", "select"),
+ ),
+ }
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding { return nil }
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{
+ key.NewBinding(
+ key.WithKeys("up", "down"),
+ key.WithHelp("↓↑", "navigate"),
+ ),
+ k.Submit,
+ }
+}
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
new file mode 100644
index 0000000000000000000000000000000000000000..4ed851ce546ed39f6b829877f4f25b55a862d091
--- /dev/null
+++ b/internal/tui/components/core/list/list.go
@@ -0,0 +1,625 @@
+package list
+
+import (
+ "slices"
+ "strings"
+ "sync"
+
+ "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/opencode-ai/opencode/internal/tui/layout"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+type ListModel interface {
+ util.Model
+ layout.Sizeable
+ SetItems([]util.Model) tea.Cmd
+ AppendItem(util.Model)
+ PrependItem(util.Model)
+ DeleteItem(int)
+ UpdateItem(int, util.Model)
+ ResetView()
+}
+
+type renderedItem struct {
+ lines []string
+ start int
+ height int
+}
+type model struct {
+ width, height, offset int
+ finalHight int // this gets set when the last item is rendered to mark the max offset
+ reverse bool
+ help help.Model
+ keymap KeyMap
+ items []util.Model
+ renderedItems *sync.Map // item index to rendered string
+ needsRerender bool
+ renderedLines []string
+ selectedItemInx int
+ lastRenderedInx int
+ content string
+ gapSize int
+ padding []int
+}
+
+type listOptions func(*model)
+
+func WithKeyMap(k KeyMap) listOptions {
+ return func(m *model) {
+ m.keymap = k
+ }
+}
+
+func WithReverse(reverse bool) listOptions {
+ return func(m *model) {
+ m.setReverse(reverse)
+ }
+}
+
+func WithGapSize(gapSize int) listOptions {
+ return func(m *model) {
+ m.gapSize = gapSize
+ }
+}
+
+func WithPadding(padding ...int) listOptions {
+ return func(m *model) {
+ m.padding = padding
+ }
+}
+
+func WithItems(items []util.Model) listOptions {
+ return func(m *model) {
+ m.items = items
+ }
+}
+
+func New(opts ...listOptions) ListModel {
+ m := &model{
+ help: help.New(),
+ keymap: defaultKeymap(),
+ items: []util.Model{},
+ needsRerender: true,
+ gapSize: 0,
+ padding: []int{},
+ selectedItemInx: -1,
+ finalHight: -1,
+ lastRenderedInx: -1,
+ renderedItems: new(sync.Map),
+ }
+ for _, opt := range opts {
+ opt(m)
+ }
+ return m
+}
+
+// Init implements List.
+func (m *model) Init() tea.Cmd {
+ cmds := []tea.Cmd{
+ m.SetItems(m.items),
+ }
+ return tea.Batch(cmds...)
+}
+
+// Update implements List.
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
+ if m.reverse {
+ m.decreaseOffset(1)
+ } else {
+ m.increaseOffset(1)
+ }
+ return m, nil
+ case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
+ if m.reverse {
+ m.increaseOffset(1)
+ } else {
+ m.decreaseOffset(1)
+ }
+ return m, nil
+ case key.Matches(msg, m.keymap.DownOneItem):
+ m.downOneItem()
+ return m, nil
+ case key.Matches(msg, m.keymap.UpOneItem):
+ m.upOneItem()
+ return m, nil
+ case key.Matches(msg, m.keymap.HalfPageDown):
+ if m.reverse {
+ m.decreaseOffset(m.listHeight() / 2)
+ } else {
+ m.increaseOffset(m.listHeight() / 2)
+ }
+ return m, nil
+ case key.Matches(msg, m.keymap.HalfPageUp):
+ if m.reverse {
+ m.increaseOffset(m.listHeight() / 2)
+ } else {
+ m.decreaseOffset(m.listHeight() / 2)
+ }
+ return m, nil
+ case key.Matches(msg, m.keymap.Home):
+ m.goToTop()
+ return m, nil
+ case key.Matches(msg, m.keymap.End):
+ m.goToBottom()
+ return m, nil
+ }
+ }
+ if m.selectedItemInx > -1 {
+ u, cmd := m.items[m.selectedItemInx].Update(msg)
+ m.UpdateItem(m.selectedItemInx, u.(util.Model))
+ return m, cmd
+ }
+
+ return m, nil
+}
+
+// View implements List.
+func (m *model) View() string {
+ if m.height == 0 || m.width == 0 {
+ return ""
+ }
+ if m.needsRerender {
+ m.renderVisible()
+ }
+ return lipgloss.NewStyle().Padding(m.padding...).Render(m.content)
+}
+
+func (m *model) renderVisibleReverse() {
+ start := 0
+ cutoff := m.offset + m.listHeight()
+ items := m.items
+ if m.lastRenderedInx > -1 {
+ items = m.items[:m.lastRenderedInx]
+ start = len(m.renderedLines)
+ } else {
+ // reveresed so that it starts at the end
+ m.lastRenderedInx = len(m.items)
+ }
+ realIndex := m.lastRenderedInx
+ for i := len(items) - 1; i >= 0; i-- {
+ realIndex--
+ var itemLines []string
+ cachedContent, ok := m.renderedItems.Load(realIndex)
+ if ok {
+ itemLines = cachedContent.(renderedItem).lines
+ } else {
+ itemLines = strings.Split(items[i].View(), "\n")
+ if m.gapSize > 0 && realIndex != len(m.items)-1 {
+ for range m.gapSize {
+ itemLines = append(itemLines, "")
+ }
+ }
+ m.renderedItems.Store(realIndex, renderedItem{
+ lines: itemLines,
+ start: start,
+ height: len(itemLines),
+ })
+ }
+
+ if realIndex == 0 {
+ m.finalHight = max(0, start+len(itemLines)-m.listHeight())
+ }
+ m.renderedLines = append(itemLines, m.renderedLines...)
+ m.lastRenderedInx = realIndex
+ // always render the next item
+ if start > cutoff {
+ break
+ }
+ start += len(itemLines)
+ }
+ m.needsRerender = false
+ if m.finalHight > -1 {
+ // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
+ m.offset = min(m.offset, m.finalHight)
+ }
+ maxHeight := min(m.listHeight(), len(m.renderedLines))
+ if m.offset < len(m.renderedLines) {
+ end := len(m.renderedLines) - m.offset
+ start := max(0, end-maxHeight)
+ m.content = strings.Join(m.renderedLines[start:end], "\n")
+ } else {
+ m.content = ""
+ }
+}
+
+func (m *model) renderVisible() {
+ if m.reverse {
+ m.renderVisibleReverse()
+ return
+ }
+ start := 0
+ cutoff := m.offset + m.listHeight()
+ items := m.items
+ if m.lastRenderedInx > -1 {
+ items = m.items[m.lastRenderedInx+1:]
+ start = len(m.renderedLines)
+ }
+
+ realIndex := m.lastRenderedInx
+ for _, item := range items {
+ realIndex++
+
+ var itemLines []string
+ cachedContent, ok := m.renderedItems.Load(realIndex)
+ if ok {
+ itemLines = cachedContent.(renderedItem).lines
+ } else {
+ itemLines = strings.Split(item.View(), "\n")
+ if m.gapSize > 0 && realIndex != len(m.items)-1 {
+ for range m.gapSize {
+ itemLines = append(itemLines, "")
+ }
+ }
+ m.renderedItems.Store(realIndex, renderedItem{
+ lines: itemLines,
+ start: start,
+ height: len(itemLines),
+ })
+ }
+ // always render the next item
+ if start > cutoff {
+ break
+ }
+
+ if realIndex == len(m.items)-1 {
+ m.finalHight = max(0, start+len(itemLines)-m.listHeight())
+ }
+
+ m.renderedLines = append(m.renderedLines, itemLines...)
+ m.lastRenderedInx = realIndex
+ start += len(itemLines)
+ }
+ m.needsRerender = false
+ maxHeight := min(m.listHeight(), len(m.renderedLines))
+ if m.finalHight > -1 {
+ // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
+ m.offset = min(m.offset, m.finalHight)
+ }
+ if m.offset < len(m.renderedLines) {
+ m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
+ } else {
+ m.content = ""
+ }
+}
+
+func (m *model) upOneItem() tea.Cmd {
+ var cmds []tea.Cmd
+ if m.selectedItemInx > 0 {
+ cmd := m.blurSelected()
+ cmds = append(cmds, cmd)
+ m.selectedItemInx--
+ cmd = m.focusSelected()
+ cmds = append(cmds, cmd)
+ }
+
+ cached, ok := m.renderedItems.Load(m.selectedItemInx)
+ if ok {
+ // already rendered
+ if !m.reverse {
+ cachedItem, _ := cached.(renderedItem)
+ // might not fit on the screen move the offset to the start of the item
+ if cachedItem.height >= m.listHeight() {
+ changeNeeded := m.offset - cachedItem.start
+ m.decreaseOffset(changeNeeded)
+ }
+ if cachedItem.start < m.offset {
+ changeNeeded := m.offset - cachedItem.start
+ m.decreaseOffset(changeNeeded)
+ }
+ } else {
+ cachedItem, _ := cached.(renderedItem)
+ // might not fit on the screen move the offset to the start of the item
+ if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
+ m.increaseOffset(changeNeeded)
+ }
+ }
+ }
+ m.needsRerender = true
+ return tea.Batch(cmds...)
+}
+
+func (m *model) downOneItem() tea.Cmd {
+ var cmds []tea.Cmd
+ if m.selectedItemInx < len(m.items)-1 {
+ cmd := m.blurSelected()
+ cmds = append(cmds, cmd)
+ m.selectedItemInx++
+ cmd = m.focusSelected()
+ cmds = append(cmds, cmd)
+ }
+ cached, ok := m.renderedItems.Load(m.selectedItemInx)
+ if ok {
+ // already rendered
+ if !m.reverse {
+ cachedItem, _ := cached.(renderedItem)
+ // might not fit on the screen move the offset to the start of the item
+ if cachedItem.height >= m.listHeight() {
+ changeNeeded := cachedItem.start - m.offset
+ m.increaseOffset(changeNeeded)
+ } else {
+ end := cachedItem.start + cachedItem.height
+ if end > m.offset+m.listHeight() {
+ changeNeeded := end - (m.offset + m.listHeight())
+ m.increaseOffset(changeNeeded)
+ }
+ }
+ } else {
+ cachedItem, _ := cached.(renderedItem)
+ // might not fit on the screen move the offset to the start of the item
+ if cachedItem.height >= m.listHeight() {
+ changeNeeded := m.offset - (cachedItem.start + cachedItem.height - m.listHeight())
+ m.decreaseOffset(changeNeeded)
+ } else {
+ if cachedItem.start < m.offset {
+ changeNeeded := m.offset - cachedItem.start
+ m.decreaseOffset(changeNeeded)
+ }
+ }
+ }
+ }
+
+ m.needsRerender = true
+ return tea.Batch(cmds...)
+}
+
+func (m *model) goToBottom() tea.Cmd {
+ var cmds []tea.Cmd
+ m.reverse = true
+ cmd := m.blurSelected()
+ cmds = append(cmds, cmd)
+ m.selectedItemInx = len(m.items) - 1
+ cmd = m.focusSelected()
+ cmds = append(cmds, cmd)
+ m.ResetView()
+ return tea.Batch(cmds...)
+}
+
+func (m *model) ResetView() {
+ m.renderedItems.Clear()
+ m.renderedLines = []string{}
+ m.offset = 0
+ m.lastRenderedInx = -1
+ m.finalHight = -1
+ m.needsRerender = true
+}
+
+func (m *model) goToTop() tea.Cmd {
+ var cmds []tea.Cmd
+ m.reverse = false
+ cmd := m.blurSelected()
+ cmds = append(cmds, cmd)
+ m.selectedItemInx = 0
+ cmd = m.focusSelected()
+ cmds = append(cmds, cmd)
+ m.ResetView()
+ return tea.Batch(cmds...)
+}
+
+func (m *model) focusSelected() tea.Cmd {
+ if m.selectedItemInx == -1 {
+ return nil
+ }
+ if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+ cmd := i.Focus()
+ m.rerenderItem(m.selectedItemInx)
+ return cmd
+ }
+ return nil
+}
+
+func (m *model) blurSelected() tea.Cmd {
+ if m.selectedItemInx == -1 {
+ return nil
+ }
+ if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+ cmd := i.Blur()
+ m.rerenderItem(m.selectedItemInx)
+ return cmd
+ }
+ return nil
+}
+
+func (m *model) rerenderItem(inx int) {
+ if inx < 0 || len(m.renderedLines) == 0 {
+ return
+ }
+ cached, ok := m.renderedItems.Load(inx)
+ cachedItem, _ := cached.(renderedItem)
+ if !ok {
+ // No need to rerender
+ return
+ }
+ rerenderedItem := m.items[inx].View()
+ rerenderedLines := strings.Split(rerenderedItem, "\n")
+ if m.gapSize > 0 && inx != len(m.items)-1 {
+ for range m.gapSize {
+ rerenderedLines = append(rerenderedLines, "")
+ }
+ }
+ // check if lines are the same
+ if slices.Equal(cachedItem.lines, rerenderedLines) {
+ // No changes
+ return
+ }
+ // check if the item is in the content
+ start := cachedItem.start
+ end := start + cachedItem.height
+ totalLines := len(m.renderedLines)
+ if m.reverse {
+ end = totalLines - cachedItem.start
+ start = end - cachedItem.height
+ }
+ if start <= totalLines && end <= totalLines {
+ m.renderedLines = slices.Delete(m.renderedLines, start, end)
+ m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
+ }
+ // TODO: if hight changed do something
+ if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
+ panic("not handled")
+ }
+ m.renderedItems.Store(inx, renderedItem{
+ lines: rerenderedLines,
+ start: cachedItem.start,
+ height: len(rerenderedLines),
+ })
+ m.needsRerender = true
+}
+
+func (m *model) increaseOffset(n int) {
+ if m.finalHight > -1 {
+ if m.offset < m.finalHight {
+ m.offset += n
+ if m.offset > m.finalHight {
+ m.offset = m.finalHight
+ }
+ m.needsRerender = true
+ }
+ } else {
+ m.offset += n
+ m.needsRerender = true
+ }
+}
+
+func (m *model) decreaseOffset(n int) {
+ if m.offset > 0 {
+ m.offset -= n
+ if m.offset < 0 {
+ m.offset = 0
+ }
+ m.needsRerender = true
+ }
+}
+
+// UpdateItem implements List.
+func (m *model) UpdateItem(inx int, item util.Model) {
+ m.items[inx] = item
+ m.rerenderItem(inx)
+ m.needsRerender = true
+}
+
+// GetSize implements List.
+func (m *model) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+// SetSize implements List.
+func (m *model) SetSize(width int, height int) tea.Cmd {
+ if m.width == width && m.height == height {
+ return nil
+ }
+ if m.height != height {
+ m.finalHight = -1
+ m.height = height
+ }
+ m.width = width
+ m.ResetView()
+ return m.setItemsSize()
+}
+
+func (m *model) setItemsSize() tea.Cmd {
+ var cmds []tea.Cmd
+ width := m.width
+ if m.padding != nil {
+ if len(m.padding) == 1 {
+ width -= m.padding[0] * 2
+ } else if len(m.padding) == 2 || len(m.padding) == 3 {
+ width -= m.padding[1] * 2
+ } else if len(m.padding) == 4 {
+ width -= m.padding[1] + m.padding[3]
+ }
+ }
+ for _, item := range m.items {
+ if i, ok := item.(layout.Sizeable); ok {
+ cmd := i.SetSize(width, 0) // height is not limited
+ cmds = append(cmds, cmd)
+ }
+ }
+ return tea.Batch(cmds...)
+}
+
+func (m *model) listHeight() int {
+ height := m.height
+ if m.padding != nil {
+ if len(m.padding) == 1 {
+ height -= m.padding[0] * 2
+ } else if len(m.padding) == 2 {
+ height -= m.padding[1] * 2
+ } else if len(m.padding) == 3 {
+ height -= m.padding[0] + m.padding[2]
+ } else if len(m.padding) == 4 {
+ height -= m.padding[0] + m.padding[2]
+ }
+ }
+ return height
+}
+
+// AppendItem implements List.
+func (m *model) AppendItem(item util.Model) {
+ m.items = append(m.items, item)
+ m.goToBottom()
+ m.needsRerender = true
+}
+
+// DeleteItem implements List.
+func (m *model) DeleteItem(i int) {
+ m.items = slices.Delete(m.items, i, i+1)
+ m.renderedItems.Delete(i)
+ m.needsRerender = true
+}
+
+// PrependItem implements List.
+func (m *model) PrependItem(item util.Model) {
+ m.items = append([]util.Model{item}, m.items...)
+ // update the indices of the rendered items
+ newRenderedItems := make(map[int]renderedItem)
+ m.renderedItems.Range(func(key any, value any) bool {
+ keyInt := key.(int)
+ renderedItem := value.(renderedItem)
+ newKey := keyInt + 1
+ newRenderedItems[newKey] = renderedItem
+ return false
+ })
+ m.renderedItems.Clear()
+ for k, v := range newRenderedItems {
+ m.renderedItems.Store(k, v)
+ }
+ m.goToTop()
+ m.needsRerender = true
+}
+
+func (m *model) setReverse(reverse bool) {
+ if reverse {
+ m.goToBottom()
+ } else {
+ m.goToTop()
+ }
+}
+
+// SetItems implements List.
+func (m *model) SetItems(items []util.Model) tea.Cmd {
+ m.items = items
+ var cmds []tea.Cmd
+ cmd := m.setItemsSize()
+ cmds = append(cmds, cmd)
+ if m.reverse {
+ m.selectedItemInx = len(m.items) - 1
+ cmd := m.focusSelected()
+ cmds = append(cmds, cmd)
+ } else {
+ m.selectedItemInx = 0
+ cmd := m.focusSelected()
+ cmds = append(cmds, cmd)
+ }
+ m.needsRerender = true
+ m.ResetView()
+ return tea.Batch(cmds...)
+}
diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go
index 7096d7d159e2f86c37d26fc74d36b2249cd72f6f..9ba3ebac1701d8446222dc7f8f704de3166823c4 100644
--- a/internal/tui/page/chat.go
+++ b/internal/tui/page/chat.go
@@ -217,7 +217,7 @@ func NewChatPage(app *app.App) util.Model {
completionDialog := dialog.NewCompletionDialogCmp(cg)
messagesContainer := layout.NewContainer(
- chat.NewMessagesCmp(app),
+ chat.NewMessagesListCmp(app),
layout.WithPadding(1, 1, 0, 1),
)
editorContainer := layout.NewContainer(
diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go
index bf9114d35ad9b2d36f742deb622a64210772bf20..39ab57d14785222dbcf88116bd62060513c01ec4 100644
--- a/internal/tui/styles/markdown.go
+++ b/internal/tui/styles/markdown.go
@@ -33,9 +33,7 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
return ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
- BlockPrefix: "",
- BlockSuffix: "",
- Color: stringPtr(colorToString(t.MarkdownText())),
+ Color: stringPtr(colorToString(t.MarkdownText())),
},
Margin: uintPtr(defaultMargin),
},
diff --git a/internal/tui/theme/opencode.go b/internal/tui/theme/opencode.go
index e4a3af7b3208e3a1c475b2333043e65c4264b3e6..40bbaeca95066615e8abc3c9e8984e8f5f530a9d 100644
--- a/internal/tui/theme/opencode.go
+++ b/internal/tui/theme/opencode.go
@@ -62,8 +62,9 @@ func NewOpenCodeDarkTheme() *OpenCodeTheme {
theme.DiffRemovedColor = lipgloss.Color("#7C4444")
theme.DiffContextColor = lipgloss.Color("#a0a0a0")
theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0")
- theme.DiffHighlightAddedColor = lipgloss.Color("#DAFADA")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#FADADD")
+ // TODO: change these colors to be what we want
+ theme.DiffHighlightAddedColor = lipgloss.Color("#256125")
+ theme.DiffHighlightRemovedColor = lipgloss.Color("#612726")
theme.DiffAddedBgColor = lipgloss.Color("#303A30")
theme.DiffRemovedBgColor = lipgloss.Color("#3A3030")
theme.DiffContextBgColor = lipgloss.Color(darkBackground)
@@ -195,4 +196,4 @@ func init() {
// Register the OpenCode themes with the theme manager
RegisterTheme("opencode-dark", NewOpenCodeDarkTheme())
RegisterTheme("opencode-light", NewOpenCodeLightTheme())
-}
\ No newline at end of file
+}
From c891295f7c51031837e6b73a2ce1343273557e23 Mon Sep 17 00:00:00 2001
From: Ayman Bagabas
Date: Wed, 21 May 2025 12:07:31 -0400
Subject: [PATCH 03/73] fix: tui: properly calculate quit dialog size
This uses the style frame size to calculate the quit dialog width.
---
internal/tui/components/dialog/quit.go | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go
index 0331a9ac5fe433b0a02b39a1f498ce8a28d4bb78..edb67f694f4161c39ae6e9ad471a7eb7e45cd65d 100644
--- a/internal/tui/components/dialog/quit.go
+++ b/internal/tui/components/dialog/quit.go
@@ -117,11 +117,14 @@ func (q *quitDialogCmp) View() string {
),
)
- return baseStyle.Padding(1, 2).
+ quitDialogStyle := baseStyle.
+ Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4).
+ BorderForeground(t.TextMuted())
+
+ return quitDialogStyle.
+ Width(lipgloss.Width(content) + quitDialogStyle.GetHorizontalFrameSize()).
Render(content)
}
From abbbc05fd74dec055b31cb7c06dcaccbd21068d0 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Thu, 22 May 2025 11:23:15 +0200
Subject: [PATCH 04/73] refactor tool rendering
---
internal/tui/components/anim/anim.go | 254 ++++++++
internal/tui/components/chat/editor.go | 1 -
internal/tui/components/chat/list_v2.go | 38 +-
internal/tui/components/chat/message.go | 57 ++
.../{message_v2.go => messages/messages.go} | 41 +-
.../tui/components/chat/messages/renderer.go | 555 ++++++++++++++++++
internal/tui/components/chat/messages/tool.go | 155 +++++
internal/tui/components/chat/tool_message.go | 365 ------------
internal/tui/components/core/list/list.go | 2 +-
internal/tui/page/chat.go | 2 +
internal/tui/theme/manager.go | 6 +
internal/tui/tui.go | 11 +-
12 files changed, 1056 insertions(+), 431 deletions(-)
create mode 100644 internal/tui/components/anim/anim.go
rename internal/tui/components/chat/{message_v2.go => messages/messages.go} (85%)
create mode 100644 internal/tui/components/chat/messages/renderer.go
create mode 100644 internal/tui/components/chat/messages/tool.go
delete mode 100644 internal/tui/components/chat/tool_message.go
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
new file mode 100644
index 0000000000000000000000000000000000000000..23eaf21c714c7370d97cf5d7ecd8a2ddc50a9aeb
--- /dev/null
+++ b/internal/tui/components/anim/anim.go
@@ -0,0 +1,254 @@
+package anim
+
+import (
+ "image/color"
+ "math/rand"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/bubbles/v2/spinner"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/lucasb-eyer/go-colorful"
+ "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 (
+ 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{}
+
+func stepChars() tea.Cmd {
+ return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
+ return StepCharsMsg{}
+ })
+}
+
+type ColorCycleMsg struct{}
+
+func cycleColors() tea.Cmd {
+ return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
+ return ColorCycleMsg{}
+ })
+}
+
+// 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
+}
+
+func New(cyclingCharsSize uint, label string) util.Model {
+ // #nosec G115
+ n := min(int(cyclingCharsSize), maxCyclingChars)
+
+ gap := " "
+ if n == 0 {
+ gap = ""
+ }
+
+ c := anim{
+ start: time.Now(),
+ label: []rune(gap + label),
+ ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
+ }
+
+ // 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.Int31n(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 (anim) Init() tea.Cmd {
+ return tea.Batch(stepChars(), cycleColors())
+}
+
+// Update handles messages.
+func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+ switch msg.(type) {
+ case StepCharsMsg:
+ 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(), cmd)
+ case ColorCycleMsg:
+ const minColorCycleSize = 2
+ if len(a.ramp) < minColorCycleSize {
+ return a, nil
+ }
+ a.ramp = append(a.ramp[1:], a.ramp[0])
+ return a, cycleColors()
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ a.ellipsis, cmd = a.ellipsis.Update(msg)
+ return a, cmd
+ default:
+ return a, nil
+ }
+}
+
+func (a *anim) updateChars(chars *[]cyclingChar) {
+ for i, c := range *chars {
+ switch c.state(a.start) {
+ case charInitialState:
+ (*chars)[i].currentValue = '.'
+ case charCyclingState:
+ (*chars)[i].currentValue = c.randomRune()
+ case charEndOfLifeState:
+ (*chars)[i].currentValue = c.finalValue
+ }
+ }
+}
+
+// View renders the animation.
+func (a anim) View() string {
+ t := theme.CurrentTheme()
+ var b strings.Builder
+
+ 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)
+ }
+
+ textStyle := styles.BaseStyle().
+ Foreground(t.Text())
+
+ for _, c := range a.labelChars {
+ b.WriteString(
+ textStyle.Render(string(c.currentValue)),
+ )
+ }
+
+ return b.String() + textStyle.Render(a.ellipsis.View())
+}
+
+func makeGradientRamp(length int) []color.Color {
+ t := theme.CurrentTheme()
+ startColor := theme.GetColor(t.Primary())
+ endColor := theme.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/editor.go b/internal/tui/components/chat/editor.go
index 4f3f69665a9af6d622214431ca563dff20764412..6dae5418d4ffeef5d4f29703b7bc45a580d9d958 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -242,7 +242,6 @@ func (m *editorCmp) SetSize(width, height int) tea.Cmd {
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
}
diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go
index 10010ab8b635f7ecfc265163e07d1c87b984e187..bec9b206e996149ac1574f99e3f71dbbfb8b280b 100644
--- a/internal/tui/components/chat/list_v2.go
+++ b/internal/tui/components/chat/list_v2.go
@@ -5,9 +5,11 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/session"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
"github.com/opencode-ai/opencode/internal/tui/components/core/list"
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
"github.com/opencode-ai/opencode/internal/tui/layout"
@@ -52,12 +54,17 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
return m, nil
+ 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...)
}
- return m, nil
}
func (m *messageListCmp) View() string {
- return m.listCmp.View()
+ return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
}
// GetSize implements MessageListCmp.
@@ -68,8 +75,8 @@ func (m *messageListCmp) GetSize() (int, int) {
// SetSize implements MessageListCmp.
func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
m.width = width
- m.height = height
- return m.listCmp.SetSize(width, height)
+ m.height = height - 1
+ return m.listCmp.SetSize(width, height-1)
}
func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
@@ -77,41 +84,38 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
return nil
}
m.session = session
- messages, err := m.app.Messages.List(context.Background(), session.ID)
+ sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
if err != nil {
return util.ReportError(err)
}
m.messages = make([]util.Model, 0)
- lastUserMessageTime := messages[0].CreatedAt
+ lastUserMessageTime := sessionMessages[0].CreatedAt
toolResultMap := make(map[string]message.ToolResult)
// first pass to get all tool results
- for _, msg := range messages {
+ for _, msg := range sessionMessages {
for _, tr := range msg.ToolResults() {
toolResultMap[tr.ToolCallID] = tr
}
}
- for _, msg := range messages {
- // TODO: handle tool calls and others here
+ for _, msg := range sessionMessages {
switch msg.Role {
case message.User:
lastUserMessageTime = msg.CreatedAt
- m.messages = append(m.messages, NewMessageCmp(WithMessage(msg)))
+ m.messages = append(m.messages, messages.NewMessageCmp(msg))
case message.Assistant:
// Only add assistant messages if they don't have tool calls or there is some content
if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
- m.messages = append(m.messages, NewMessageCmp(WithMessage(msg), WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
+ m.messages = append(m.messages, messages.NewMessageCmp(msg, messages.WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
}
for _, tc := range msg.ToolCalls() {
- options := []MessageOption{
- WithToolCall(tc),
- }
+ options := []messages.ToolCallOption{}
if tr, ok := toolResultMap[tc.ID]; ok {
- options = append(options, WithToolResult(tr))
+ options = append(options, messages.WithToolCallResult(tr))
}
if msg.FinishPart().Reason == message.FinishReasonCanceled {
- options = append(options, WithCancelledToolCall(true))
+ options = append(options, messages.WithToolCallCancelled())
}
- m.messages = append(m.messages, NewMessageCmp(options...))
+ m.messages = append(m.messages, messages.NewToolCallCmp(tc, options...))
}
}
}
diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go
index 96a33da9150cd135d006007a0d659217c261f738..fa96c54fb27af23341b425bfccc88d2bcdaa1322 100644
--- a/internal/tui/components/chat/message.go
+++ b/internal/tui/components/chat/message.go
@@ -10,6 +10,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"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"
@@ -559,6 +560,62 @@ func renderToolMessage(
return toolMsg
}
+func removeWorkingDirPrefix(path string) string {
+ wd := config.WorkingDirectory()
+ path = strings.TrimPrefix(path, wd)
+ return path
+}
+
+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 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) - 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, "...")
+}
+
// 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
diff --git a/internal/tui/components/chat/message_v2.go b/internal/tui/components/chat/messages/messages.go
similarity index 85%
rename from internal/tui/components/chat/message_v2.go
rename to internal/tui/components/chat/messages/messages.go
index 1e281ec01b2fe3c72dd1a6faf62da4a685404348..10d82961348413f08ced31e88385099bf85fa091 100644
--- a/internal/tui/components/chat/message_v2.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -1,4 +1,4 @@
-package chat
+package messages
import (
"fmt"
@@ -31,11 +31,6 @@ type messageCmp struct {
// Used for agent and user messages
message message.Message
lastUserMessageTime time.Time
-
- // Used for tool calls
- toolCall message.ToolCall
- toolResult message.ToolResult
- cancelledToolCall bool
}
type MessageOption func(*messageCmp)
@@ -46,32 +41,10 @@ func WithLastUserMessageTime(t time.Time) MessageOption {
}
}
-func WithToolCall(tc message.ToolCall) MessageOption {
- return func(m *messageCmp) {
- m.toolCall = tc
+func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
+ m := &messageCmp{
+ message: msg,
}
-}
-
-func WithToolResult(tr message.ToolResult) MessageOption {
- return func(m *messageCmp) {
- m.toolResult = tr
- }
-}
-
-func WithMessage(msg message.Message) MessageOption {
- return func(m *messageCmp) {
- m.message = msg
- }
-}
-
-func WithCancelledToolCall(cancelled bool) MessageOption {
- return func(m *messageCmp) {
- m.cancelledToolCall = cancelled
- }
-}
-
-func NewMessageCmp(opts ...MessageOption) MessageCmp {
- m := &messageCmp{}
for _, opt := range opts {
opt(m)
}
@@ -95,17 +68,11 @@ func (m *messageCmp) View() string {
default:
return m.renderAssistantMessage()
}
- } else if m.toolCall.ID != "" {
- // this is a tool call message
- return m.renderToolCallMessage()
}
return "Unknown Message"
}
func (m *messageCmp) textWidth() int {
- if m.toolCall.ID != "" {
- return m.width - 2 // take into account the border and PaddingLeft
- }
return m.width - 1 // take into account the border
}
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
new file mode 100644
index 0000000000000000000000000000000000000000..ebea3eff09461fa87a44c0789e39dff232e023d7
--- /dev/null
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -0,0 +1,555 @@
+package messages
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/lipgloss/v2"
+ "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/highlight"
+ "github.com/opencode-ai/opencode/internal/llm/agent"
+ "github.com/opencode-ai/opencode/internal/llm/tools"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/theme"
+)
+
+const responseContextHeight = 10
+
+type renderer interface {
+ // Render returns the complete (already styled) tool‑call view, not
+ // including the outer border.
+ Render(v *toolCallCmp) string
+}
+
+type rendererFactory func() renderer
+
+type renderRegistry map[string]rendererFactory
+
+func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
+func (rr renderRegistry) lookup(name string) renderer {
+ if f, ok := rr[name]; ok {
+ return f()
+ }
+ return genericRenderer{} // sensible fallback
+}
+
+var registry = renderRegistry{}
+
+// Registger 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{} })
+}
+
+// -----------------------------------------------------------------------------
+// Generic renderer
+// -----------------------------------------------------------------------------
+
+type genericRenderer struct{}
+
+func (genericRenderer) Render(v *toolCallCmp) string {
+ header := makeHeader(prettifyToolName(v.call.Name), v.textWidth(), v.call.Input)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+ body := renderPlainContent(v, v.result.Content)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Bash renderer
+// -----------------------------------------------------------------------------
+
+type bashRenderer struct{}
+
+func (bashRenderer) Render(v *toolCallCmp) string {
+ var p tools.BashParams
+ _ = json.Unmarshal([]byte(v.call.Input), &p)
+
+ cmd := strings.ReplaceAll(p.Command, "\n", " ")
+ header := makeHeader("Bash", v.textWidth(), cmd)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+ body := renderPlainContent(v, v.result.Content)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// View renderer
+// -----------------------------------------------------------------------------
+
+type viewRenderer struct{}
+
+func (viewRenderer) Render(v *toolCallCmp) string {
+ var params tools.ViewParams
+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+
+ file := removeWorkingDirPrefix(params.FilePath)
+ args := []string{file}
+ if params.Limit != 0 {
+ args = append(args, "limit", fmt.Sprintf("%d", params.Limit))
+ }
+ if params.Offset != 0 {
+ args = append(args, "offset", fmt.Sprintf("%d", params.Offset))
+ }
+
+ header := makeHeader("View", v.textWidth(), args...)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ var meta tools.ViewResponseMetadata
+ _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+
+ body := renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Edit renderer
+// -----------------------------------------------------------------------------
+
+type editRenderer struct{}
+
+func (editRenderer) Render(v *toolCallCmp) string {
+ var params tools.EditParams
+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+
+ file := removeWorkingDirPrefix(params.FilePath)
+ header := makeHeader("Edit", v.textWidth(), file)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ var meta tools.EditResponseMetadata
+ _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+
+ trunc := truncateHeight(meta.Diff, responseContextHeight)
+ diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
+ return joinHeaderBody(header, diffView)
+}
+
+// -----------------------------------------------------------------------------
+// Write renderer
+// -----------------------------------------------------------------------------
+
+type writeRenderer struct{}
+
+func (writeRenderer) Render(v *toolCallCmp) string {
+ var params tools.WriteParams
+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+
+ file := removeWorkingDirPrefix(params.FilePath)
+ header := makeHeader("Write", v.textWidth(), file)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ body := renderCodeContent(v, file, params.Content, 0)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Fetch renderer
+// -----------------------------------------------------------------------------
+
+type fetchRenderer struct{}
+
+func (fetchRenderer) Render(v *toolCallCmp) string {
+ var params tools.FetchParams
+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+
+ args := []string{params.URL}
+ if params.Format != "" {
+ args = append(args, "format", params.Format)
+ }
+ if params.Timeout != 0 {
+ args = append(args, "timeout", (time.Duration(params.Timeout) * time.Second).String())
+ }
+
+ header := makeHeader("Fetch", v.textWidth(), args...)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ file := "fetch.md"
+ switch params.Format {
+ case "text":
+ file = "fetch.txt"
+ case "html":
+ file = "fetch.html"
+ }
+
+ body := renderCodeContent(v, file, v.result.Content, 0)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Glob renderer
+// -----------------------------------------------------------------------------
+
+type globRenderer struct{}
+
+func (globRenderer) Render(v *toolCallCmp) string {
+ var params tools.GlobParams
+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+
+ args := []string{params.Pattern}
+ if params.Path != "" {
+ args = append(args, "path", params.Path)
+ }
+
+ header := makeHeader("Glob", v.textWidth(), args...)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ body := renderPlainContent(v, v.result.Content)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Grep renderer
+// -----------------------------------------------------------------------------
+
+type grepRenderer struct{}
+
+func (grepRenderer) Render(v *toolCallCmp) string {
+ var params tools.GrepParams
+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+
+ args := []string{params.Pattern}
+ if params.Path != "" {
+ args = append(args, "path", params.Path)
+ }
+ if params.Include != "" {
+ args = append(args, "include", params.Include)
+ }
+ if params.LiteralText {
+ args = append(args, "literal", "true")
+ }
+
+ header := makeHeader("Grep", v.textWidth(), args...)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ body := renderPlainContent(v, v.result.Content)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// LS renderer
+// -----------------------------------------------------------------------------
+
+type lsRenderer struct{}
+
+func (lsRenderer) Render(v *toolCallCmp) string {
+ var params tools.LSParams
+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+
+ path := params.Path
+ if path == "" {
+ path = "."
+ }
+
+ header := makeHeader("List", v.textWidth(), path)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ body := renderPlainContent(v, v.result.Content)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Sourcegraph renderer
+// -----------------------------------------------------------------------------
+
+type sourcegraphRenderer struct{}
+
+func (sourcegraphRenderer) Render(v *toolCallCmp) string {
+ var params tools.SourcegraphParams
+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+
+ args := []string{params.Query}
+ if params.Count != 0 {
+ args = append(args, "count", fmt.Sprintf("%d", params.Count))
+ }
+ if params.ContextWindow != 0 {
+ args = append(args, "context", fmt.Sprintf("%d", params.ContextWindow))
+ }
+
+ header := makeHeader("Sourcegraph", v.textWidth(), args...)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ body := renderPlainContent(v, v.result.Content)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Patch renderer
+// -----------------------------------------------------------------------------
+
+type patchRenderer struct{}
+
+func (patchRenderer) Render(v *toolCallCmp) string {
+ var params tools.PatchParams
+ _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+
+ header := makeHeader("Patch", v.textWidth(), "multiple files")
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ var meta tools.PatchResponseMetadata
+ _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+
+ // Format the result as a summary of changes
+ summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
+ len(meta.FilesChanged), meta.Additions, meta.Removals)
+
+ // List the changed files
+ filesList := strings.Join(meta.FilesChanged, "\n")
+
+ body := renderPlainContent(v, summary+"\n\n"+filesList)
+ return joinHeaderBody(header, body)
+}
+
+// -----------------------------------------------------------------------------
+// Diagnostics renderer
+// -----------------------------------------------------------------------------
+
+type diagnosticsRenderer struct{}
+
+func (diagnosticsRenderer) Render(v *toolCallCmp) string {
+ header := makeHeader("Diagnostics", v.textWidth(), "project")
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+
+ body := renderPlainContent(v, v.result.Content)
+ return joinHeaderBody(header, body)
+}
+
+// makeHeader builds ": param (key=value)" and truncates as needed.
+func makeHeader(tool string, width int, params ...string) string {
+ prefix := tool + ": "
+ return prefix + renderParams(width-lipgloss.Width(prefix), params...)
+}
+
+// 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) - 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) {
+ switch {
+ case v.result.IsError:
+ return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
+ case v.cancelled:
+ return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
+ case v.result.ToolCallID == "":
+ return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
+ default:
+ return "", false
+ }
+}
+
+func joinHeaderBody(header, body string) string {
+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
+}
+
+func renderPlainContent(v *toolCallCmp, content string) string {
+ t := theme.CurrentTheme()
+ content = strings.TrimSpace(content)
+ lines := strings.Split(content, "\n")
+
+ var out []string
+ for i, ln := range lines {
+ if i >= responseContextHeight {
+ break
+ }
+ ln = " " + ln // left padding
+ if len(ln) > v.textWidth() {
+ ln = v.fit(ln, v.textWidth())
+ }
+ out = append(out, lipgloss.NewStyle().
+ Width(v.textWidth()).
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(ln))
+ }
+
+ if len(lines) > responseContextHeight {
+ out = append(out, lipgloss.NewStyle().
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
+ }
+ return strings.Join(out, "\n")
+}
+
+func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
+ t := theme.CurrentTheme()
+ truncated := truncateHeight(content, responseContextHeight)
+
+ highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BackgroundSecondary())
+ lines := strings.Split(highlighted, "\n")
+
+ if len(strings.Split(content, "\n")) > responseContextHeight {
+ lines = append(lines, lipgloss.NewStyle().
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
+ }
+
+ for i, ln := range lines {
+ num := lipgloss.NewStyle().
+ PaddingLeft(4).PaddingRight(2).
+ Background(t.BackgroundSecondary()).
+ Foreground(t.TextMuted()).
+ Render(fmt.Sprintf("%d", i+1+offset))
+ w := v.textWidth() - lipgloss.Width(num)
+ lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
+ num,
+ lipgloss.NewStyle().
+ Width(w).
+ Background(t.BackgroundSecondary()).
+ Render(v.fit(ln, w)))
+ }
+ return lipgloss.JoinVertical(lipgloss.Left, lines...)
+}
+
+func (v *toolCallCmp) renderToolError() string {
+ t := theme.CurrentTheme()
+ err := strings.ReplaceAll(v.result.Content, "\n", " ")
+ err = fmt.Sprintf("Error: %s", err)
+ return styles.BaseStyle().Foreground(t.Error()).Render(v.fit(err, v.textWidth()))
+}
+
+func removeWorkingDirPrefix(path string) string {
+ wd := config.WorkingDirectory()
+ return strings.TrimPrefix(path, wd)
+}
+
+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
+ }
+}
+
+func toolAction(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..."
+ default:
+ return "Working..."
+ }
+}
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
new file mode 100644
index 0000000000000000000000000000000000000000..5547170e630142f07152db19f0371c6b816a5819
--- /dev/null
+++ b/internal/tui/components/chat/messages/tool.go
@@ -0,0 +1,155 @@
+package messages
+
+import (
+ "fmt"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/opencode-ai/opencode/internal/llm/agent"
+ "github.com/opencode-ai/opencode/internal/llm/tools"
+ "github.com/opencode-ai/opencode/internal/message"
+ "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 ToolCallCmp interface {
+ util.Model
+ layout.Sizeable
+ layout.Focusable
+}
+
+type toolCallCmp struct {
+ width int
+ focused bool
+
+ call message.ToolCall
+ result message.ToolResult
+ cancelled bool
+}
+
+type ToolCallOption func(*toolCallCmp)
+
+func WithToolCallCancelled() ToolCallOption {
+ return func(m *toolCallCmp) {
+ m.cancelled = true
+ }
+}
+
+func WithToolCallResult(result message.ToolResult) ToolCallOption {
+ return func(m *toolCallCmp) {
+ m.result = result
+ }
+}
+
+func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
+ m := &toolCallCmp{
+ call: tc,
+ }
+ for _, opt := range opts {
+ opt(m)
+ }
+ return m
+}
+
+func (m *toolCallCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ return m, nil
+}
+
+func (m *toolCallCmp) View() string {
+ box := m.style()
+
+ if !m.call.Finished && !m.cancelled {
+ return box.PaddingLeft(1).Render(m.renderPending())
+ }
+
+ r := registry.lookup(m.call.Name)
+ return box.PaddingLeft(1).Render(r.Render(m))
+}
+
+func (v *toolCallCmp) renderPending() string {
+ return fmt.Sprintf("%s: %s", prettifyToolName(v.call.Name), toolAction(v.call.Name))
+}
+
+func (msg *toolCallCmp) style() lipgloss.Style {
+ t := theme.CurrentTheme()
+ borderStyle := lipgloss.NormalBorder()
+ if msg.focused {
+ borderStyle = lipgloss.DoubleBorder()
+ }
+ return styles.BaseStyle().
+ BorderLeft(true).
+ Foreground(t.TextMuted()).
+ BorderForeground(t.TextMuted()).
+ BorderStyle(borderStyle)
+}
+
+func (m *toolCallCmp) textWidth() int {
+ return m.width - 2 // take into account the border and PaddingLeft
+}
+
+func (m *toolCallCmp) fit(content string, width int) string {
+ t := theme.CurrentTheme()
+ lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
+ dots := lineStyle.Render("...")
+ return ansi.Truncate(content, width, dots)
+}
+
+func (m *toolCallCmp) toolName() string {
+ switch m.call.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 m.call.Name
+ }
+}
+
+func (m *toolCallCmp) Blur() tea.Cmd {
+ m.focused = false
+ return nil
+}
+
+func (m *toolCallCmp) Focus() tea.Cmd {
+ m.focused = true
+ return nil
+}
+
+// IsFocused implements MessageModel.
+func (m *toolCallCmp) IsFocused() bool {
+ return m.focused
+}
+
+func (m *toolCallCmp) GetSize() (int, int) {
+ return m.width, 0
+}
+
+func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
+ m.width = width
+ return nil
+}
diff --git a/internal/tui/components/chat/tool_message.go b/internal/tui/components/chat/tool_message.go
deleted file mode 100644
index 60333c2e6d3ab3fb70c670fa07b573c84b8fa37c..0000000000000000000000000000000000000000
--- a/internal/tui/components/chat/tool_message.go
+++ /dev/null
@@ -1,365 +0,0 @@
-package chat
-
-import (
- "encoding/json"
- "fmt"
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- "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/highlight"
- "github.com/opencode-ai/opencode/internal/llm/agent"
- "github.com/opencode-ai/opencode/internal/llm/tools"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
-)
-
-const responseContextHeight = 10
-
-func (m *messageCmp) renderUnfinishedToolCall() string {
- toolName := m.toolName()
- toolAction := m.getToolAction()
- return fmt.Sprintf("%s: %s", toolName, toolAction)
-}
-
-func (m *messageCmp) renderToolError() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
- err := strings.ReplaceAll(m.toolResult.Content, "\n", " ")
- err = fmt.Sprintf("Error: %s", err)
- return baseStyle.Foreground(t.Error()).Render(m.fit(err))
-}
-
-func (m *messageCmp) renderBashTool() string {
- name := m.toolName()
- prefix := fmt.Sprintf("%s: ", name)
- var params tools.BashParams
- json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
- command := strings.ReplaceAll(params.Command, "\n", " ")
- header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), command)
-
- if result, ok := m.toolResultErrorOrMissing(header); ok {
- return result
- }
- return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
-}
-
-func (m *messageCmp) renderViewTool() string {
- name := m.toolName()
- prefix := fmt.Sprintf("%s: ", name)
- var params tools.ViewParams
- json.Unmarshal([]byte(m.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))
- }
- header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), toolParams...)
-
- if result, ok := m.toolResultErrorOrMissing(header); ok {
- return result
- }
-
- metadata := tools.ViewResponseMetadata{}
- json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
-
- return m.renderTool(header, m.renderCodeContent(metadata.FilePath, metadata.Content, params.Offset))
-}
-
-func (m *messageCmp) renderCodeContent(path, content string, offset int) string {
- t := theme.CurrentTheme()
- originalHeight := lipgloss.Height(content)
- fileContent := truncateHeight(content, responseContextHeight)
-
- highlighted, _ := highlight.SyntaxHighlight(fileContent, path, t.BackgroundSecondary())
-
- lines := strings.Split(highlighted, "\n")
-
- if originalHeight > responseContextHeight {
- lines = append(lines,
- lipgloss.NewStyle().Background(t.BackgroundSecondary()).
- Foreground(t.TextMuted()).
- Render(
- fmt.Sprintf("... (%d lines)", originalHeight-responseContextHeight),
- ),
- )
- }
- for i, line := range lines {
- lineNumber := lipgloss.NewStyle().
- PaddingLeft(4).
- PaddingRight(2).
- Background(t.BackgroundSecondary()).
- Foreground(t.TextMuted()).
- Render(fmt.Sprintf("%d", i+1+offset))
- formattedLine := lipgloss.NewStyle().
- Width(m.textWidth() - lipgloss.Width(lineNumber)).
- Background(t.BackgroundSecondary()).Render(line)
- lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, lineNumber, formattedLine)
- }
- return lipgloss.NewStyle().Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- lines...,
- ),
- )
-}
-
-func (m *messageCmp) renderPlainContent(content string) string {
- t := theme.CurrentTheme()
- content = strings.TrimSuffix(content, "\n")
- content = strings.TrimPrefix(content, "\n")
- lines := strings.Split(fmt.Sprintf("\n%s\n", content), "\n")
-
- for i, line := range lines {
- line = " " + line // add padding
- if len(line) > m.textWidth() {
- line = m.fit(line)
- }
- lines[i] = lipgloss.NewStyle().
- Width(m.textWidth()).
- Background(t.BackgroundSecondary()).
- Foreground(t.TextMuted()).
- Render(line)
- }
- if len(lines) > responseContextHeight {
- lines = lines[:responseContextHeight]
- lines = append(lines,
- lipgloss.NewStyle().Background(t.BackgroundSecondary()).
- Foreground(t.TextMuted()).
- Render(
- fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight),
- ),
- )
- }
- return strings.Join(lines, "\n")
-}
-
-func (m *messageCmp) renderGenericTool() string {
- // Tool params
- name := m.toolName()
- prefix := fmt.Sprintf("%s: ", name)
- input := strings.ReplaceAll(m.toolCall.Input, "\n", " ")
- params := renderParams(m.textWidth()-lipgloss.Width(prefix), input)
- header := prefix + params
-
- if result, ok := m.toolResultErrorOrMissing(header); ok {
- return result
- }
- return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
-}
-
-func (m *messageCmp) renderEditTool() string {
- // Tool params
- name := m.toolName()
- prefix := fmt.Sprintf("%s: ", name)
- var params tools.EditParams
- json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
- filePath := removeWorkingDirPrefix(params.FilePath)
- header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
-
- if result, ok := m.toolResultErrorOrMissing(header); ok {
- return result
- }
- metadata := tools.EditResponseMetadata{}
- json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
- truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
- formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(m.textWidth()))
- return m.renderTool(header, formattedDiff)
-}
-
-func (m *messageCmp) renderWriteTool() string {
- // Tool params
- name := m.toolName()
- prefix := fmt.Sprintf("%s: ", name)
- var params tools.WriteParams
- json.Unmarshal([]byte(m.toolCall.Input), ¶ms)
- filePath := removeWorkingDirPrefix(params.FilePath)
- header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
- if result, ok := m.toolResultErrorOrMissing(header); ok {
- return result
- }
- return m.renderTool(header, m.renderCodeContent(filePath, params.Content, 0))
-}
-
-func (m *messageCmp) renderToolCallMessage() string {
- if !m.toolCall.Finished && !m.cancelledToolCall {
- return m.renderUnfinishedToolCall()
- }
- content := ""
- switch m.toolCall.Name {
- case tools.ViewToolName:
- content = m.renderViewTool()
- case tools.BashToolName:
- content = m.renderBashTool()
- case tools.EditToolName:
- content = m.renderEditTool()
- case tools.WriteToolName:
- content = m.renderWriteTool()
- default:
- content = m.renderGenericTool()
- }
- return m.style().PaddingLeft(1).Render(content)
-}
-
-func (m *messageCmp) toolResultErrorOrMissing(header string) (string, bool) {
- result := "Waiting for tool to finish..."
- if m.toolResult.IsError {
- result = m.renderToolError()
- return lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- result,
- ), true
- } else if m.cancelledToolCall {
- result = "Cancelled"
- return lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- result,
- ), true
- } else if m.toolResult.ToolCallID == "" {
- return lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- result,
- ), true
- }
-
- return "", false
-}
-
-func (m *messageCmp) renderTool(header, result string) string {
- return lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- "",
- result,
- "",
- )
-}
-
-func removeWorkingDirPrefix(path string) string {
- wd := config.WorkingDirectory()
- path = strings.TrimPrefix(path, wd)
- return path
-}
-
-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 (m *messageCmp) fit(content string) string {
- return ansi.Truncate(content, m.textWidth(), "...")
-}
-
-func (m *messageCmp) toolName() string {
- switch m.toolCall.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 m.toolCall.Name
- }
-}
-
-func (m *messageCmp) getToolAction() string {
- switch m.toolCall.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..."
- default:
- 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) - 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, "...")
-}
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 4ed851ce546ed39f6b829877f4f25b55a862d091..f8b0b4fe6a8f56d9cad4d414a581f93cdea01cb1 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -169,7 +169,7 @@ func (m *model) View() string {
if m.needsRerender {
m.renderVisible()
}
- return lipgloss.NewStyle().Padding(m.padding...).Render(m.content)
+ return lipgloss.NewStyle().Padding(m.padding...).Height(m.height).Render(m.content)
}
func (m *model) renderVisibleReverse() {
diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go
index 9ba3ebac1701d8446222dc7f8f704de3166823c4..37be69bdaf32f1bbb96a422350e16d681a6c7800 100644
--- a/internal/tui/page/chat.go
+++ b/internal/tui/page/chat.go
@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/completions"
+ "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/chat"
@@ -62,6 +63,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
+ logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
cmd := p.layout.SetSize(msg.Width, msg.Height)
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
diff --git a/internal/tui/theme/manager.go b/internal/tui/theme/manager.go
index a81ba45c12db73823f41fa416778895046dd5ec7..e00c9f0ec9ab83dafda8c7fa97ed5496cd0a7ebb 100644
--- a/internal/tui/theme/manager.go
+++ b/internal/tui/theme/manager.go
@@ -2,6 +2,7 @@ package theme
import (
"fmt"
+ "image/color"
"slices"
"strings"
"sync"
@@ -74,6 +75,11 @@ func CurrentTheme() Theme {
return globalManager.themes[globalManager.currentName]
}
+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)
+}
+
// CurrentThemeName returns the name of the currently active theme.
func CurrentThemeName() string {
globalManager.mu.RLock()
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 92c2177ade6b0e604505b222ef299f3f4b8ca94b..3d4b54e790cdded0caa4ab1d3fb0863d28f51b94 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -186,6 +186,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
+ logging.Info("Window size changed main: ", "Width", msg.Width, "Height", msg.Height)
msg.Height -= 1 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
@@ -674,15 +675,6 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
a.commands = append(a.commands, cmd)
}
-func (a *appModel) findCommand(id string) (dialog.Command, bool) {
- for _, cmd := range a.commands {
- if cmd.ID == id {
- return cmd, true
- }
- }
- return dialog.Command{}, false
-}
-
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
@@ -709,7 +701,6 @@ func (a appModel) View() string {
components := []string{
a.pages[a.currentPage].View(),
}
-
components = append(components, a.status.View())
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
From 25e9651655f1e2e1226297a28ad47948b722e7ab Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Thu, 22 May 2025 14:23:23 +0200
Subject: [PATCH 05/73] add anim to the messages
---
internal/tui/components/anim/anim.go | 18 +--
internal/tui/components/chat/chat.go | 38 ++---
internal/tui/components/chat/list.go | 15 +-
internal/tui/components/chat/list_v2.go | 131 +++++++++++++++---
.../tui/components/chat/messages/messages.go | 43 +++++-
internal/tui/components/chat/messages/tool.go | 20 ++-
internal/tui/components/chat/sidebar.go | 4 +-
internal/tui/components/core/list/list.go | 58 +++++++-
8 files changed, 257 insertions(+), 70 deletions(-)
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index 23eaf21c714c7370d97cf5d7ecd8a2ddc50a9aeb..aed03d946d97a7e59a8fb4d08ba8a0c2bd30ffad 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -216,16 +216,18 @@ func (a anim) View() string {
b.WriteRune(c.currentValue)
}
- textStyle := styles.BaseStyle().
- Foreground(t.Text())
-
- for _, c := range a.labelChars {
- b.WriteString(
- textStyle.Render(string(c.currentValue)),
- )
+ if len(a.label) > 1 {
+ textStyle := styles.BaseStyle().
+ Foreground(t.Text())
+ for _, c := range a.labelChars {
+ b.WriteString(
+ textStyle.Render(string(c.currentValue)),
+ )
+ }
+ return b.String() + textStyle.Render(a.ellipsis.View())
}
- return b.String() + textStyle.Render(a.ellipsis.View())
+ return b.String()
}
func makeGradientRamp(length int) []color.Color {
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index f7ccea001e1b03e08f16170c1d86db13729853e4..d261902102ddcf20edf9d735ea6b7808195a163d 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -5,7 +5,6 @@ import (
"sort"
"github.com/charmbracelet/lipgloss/v2"
- "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"
@@ -25,26 +24,24 @@ type SessionClearedMsg struct{}
type EditorFocusMsg bool
-func header(width int) string {
+func header() string {
return lipgloss.JoinVertical(
lipgloss.Top,
- logo(width),
- repo(width),
+ logo(),
+ repo(),
"",
- cwd(width),
+ cwd(),
)
}
-func lspsConfigured(width int) string {
+func lspsConfigured() 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)
@@ -64,7 +61,6 @@ func lspsConfigured(width int) string {
Render(fmt.Sprintf("• %s", name))
cmd := lsp.Command
- cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
lspPath := baseStyle.
Foreground(t.TextMuted()).
@@ -72,7 +68,6 @@ func lspsConfigured(width int) string {
lspViews = append(lspViews,
baseStyle.
- Width(width).
Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
@@ -84,7 +79,6 @@ func lspsConfigured(width int) string {
}
return baseStyle.
- Width(width).
Render(
lipgloss.JoinVertical(
lipgloss.Left,
@@ -97,7 +91,7 @@ func lspsConfigured(width int) string {
)
}
-func logo(width int) string {
+func logo() string {
logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
@@ -108,7 +102,6 @@ func logo(width int) string {
return baseStyle.
Bold(true).
- Width(width).
Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
@@ -119,22 +112,33 @@ func logo(width int) string {
)
}
-func repo(width int) string {
+func repo() string {
repo := "https://github.com/opencode-ai/opencode"
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
- Width(width).
Render(repo)
}
-func cwd(width int) string {
+func cwd() string {
cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
- Width(width).
Render(cwd)
}
+
+func initialScreen() string {
+ baseStyle := styles.BaseStyle()
+
+ return baseStyle.Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(),
+ "",
+ lspsConfigured(),
+ ),
+ )
+}
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index 9cfb5c51cc91723fcedd3a03d1e11c173c33509d..7bc3c4e81281c7ce1e41153315db89bdd08d79d3 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -282,7 +282,7 @@ func (m *messagesCmp) View() string {
Width(m.width).
Height(m.height - 1).
Render(
- m.initialScreen(),
+ initialScreen(),
)
return baseStyle.
@@ -400,19 +400,6 @@ func (m *messagesCmp) help() string {
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)
diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go
index bec9b206e996149ac1574f99e3f71dbbfb8b280b..d2ad3e4e95d0d53899b662037b14c75c27e221f2 100644
--- a/internal/tui/components/chat/list_v2.go
+++ b/internal/tui/components/chat/list_v2.go
@@ -8,6 +8,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"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/chat/messages"
"github.com/opencode-ai/opencode/internal/tui/components/core/list"
@@ -25,8 +26,9 @@ type messageListCmp struct {
app *app.App
width, height int
session session.Session
- messages []util.Model
listCmp list.ListModel
+
+ lastUserMessageTime int64
}
func NewMessagesListCmp(app *app.App) MessageListCmp {
@@ -54,6 +56,12 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
return m, nil
+ case SessionClearedMsg:
+ m.session = session.Session{}
+ return m, m.listCmp.SetItems([]util.Model{})
+
+ case pubsub.Event[message.Message]:
+ return m, m.handleMessageEvent(msg)
default:
var cmds []tea.Cmd
u, cmd := m.listCmp.Update(msg)
@@ -64,19 +72,91 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *messageListCmp) View() string {
+ if len(m.listCmp.Items()) == 0 {
+ return initialScreen()
+ }
return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
}
-// GetSize implements MessageListCmp.
-func (m *messageListCmp) GetSize() (int, int) {
- return m.width, m.height
+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
+ // TODO: update the agent tool message with the changes
}
-// SetSize implements MessageListCmp.
-func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
- m.width = width
- m.height = height - 1
- return m.listCmp.SetSize(width, height-1)
+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
+ switch event.Type {
+ case pubsub.CreatedEvent:
+ if event.Payload.SessionID != m.session.ID {
+ m.handleChildSession(event)
+ }
+ messageExists := false
+ // more likely to be at the end of the list
+ items := m.listCmp.Items()
+ for i := len(items) - 1; i >= 0; i-- {
+ msg := items[i].(messages.MessageCmp)
+ if msg.GetMessage().ID == event.Payload.ID {
+ messageExists = true
+ break
+ }
+ }
+ if messageExists {
+ return nil
+ }
+ switch event.Payload.Role {
+ case message.User:
+ return m.handleNewUserMessage(event.Payload)
+ case message.Assistant:
+ return m.handleNewAssistantMessage(event.Payload)
+ }
+ // TODO: handle tools
+ case pubsub.UpdatedEvent:
+ return m.handleUpdateAssistantMessage(event.Payload)
+ }
+ return nil
+}
+
+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
+ m.lastUserMessageTime = msg.CreatedAt
+ return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
+}
+
+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
+ // Simple update the content
+ items := m.listCmp.Items()
+ lastItem := items[len(items)-1].(messages.MessageCmp)
+ // TODO:handle tool calls
+ if lastItem.GetMessage().ID != msg.ID {
+ return nil
+ }
+ // for now just updet the last message
+ if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
+ m.listCmp.UpdateItem(
+ len(items)-1,
+ messages.NewMessageCmp(
+ msg,
+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+ ),
+ )
+ }
+ return nil
+}
+
+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
+ var cmds []tea.Cmd
+ // Only add assistant messages if they don't have tool calls or there is some content
+ if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
+ cmd := m.listCmp.AppendItem(
+ messages.NewMessageCmp(
+ msg,
+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+ ),
+ )
+ cmds = append(cmds, cmd)
+ }
+ for _, tc := range msg.ToolCalls() {
+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(tc))
+ cmds = append(cmds, cmd)
+ }
+ return tea.Batch(cmds...)
}
func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
@@ -88,8 +168,8 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
if err != nil {
return util.ReportError(err)
}
- m.messages = make([]util.Model, 0)
- lastUserMessageTime := sessionMessages[0].CreatedAt
+ uiMessages := make([]util.Model, 0)
+ m.lastUserMessageTime = sessionMessages[0].CreatedAt
toolResultMap := make(map[string]message.ToolResult)
// first pass to get all tool results
for _, msg := range sessionMessages {
@@ -100,12 +180,18 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
for _, msg := range sessionMessages {
switch msg.Role {
case message.User:
- lastUserMessageTime = msg.CreatedAt
- m.messages = append(m.messages, messages.NewMessageCmp(msg))
+ m.lastUserMessageTime = msg.CreatedAt
+ uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
case message.Assistant:
// Only add assistant messages if they don't have tool calls or there is some content
if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
- m.messages = append(m.messages, messages.NewMessageCmp(msg, messages.WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
+ uiMessages = append(
+ uiMessages,
+ messages.NewMessageCmp(
+ msg,
+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+ ),
+ )
}
for _, tc := range msg.ToolCalls() {
options := []messages.ToolCallOption{}
@@ -115,10 +201,21 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
if msg.FinishPart().Reason == message.FinishReasonCanceled {
options = append(options, messages.WithToolCallCancelled())
}
- m.messages = append(m.messages, messages.NewToolCallCmp(tc, options...))
+ uiMessages = append(uiMessages, messages.NewToolCallCmp(tc, options...))
}
}
}
- m.listCmp.SetItems(m.messages)
- return nil
+ return m.listCmp.SetItems(uiMessages)
+}
+
+// GetSize implements MessageListCmp.
+func (m *messageListCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+// SetSize implements MessageListCmp.
+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
+ m.width = width
+ m.height = height - 1
+ return m.listCmp.SetSize(width, height-1)
}
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index 10d82961348413f08ced31e88385099bf85fa091..3ae278a496df5ee60472172bc761bd07e43d8662 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -12,6 +12,7 @@ import (
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/message"
+ "github.com/opencode-ai/opencode/internal/tui/components/anim"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -22,6 +23,8 @@ type MessageCmp interface {
util.Model
layout.Sizeable
layout.Focusable
+ GetMessage() message.Message
+ Spinning() bool
}
type messageCmp struct {
@@ -30,9 +33,10 @@ type messageCmp struct {
// Used for agent and user messages
message message.Message
+ spinning bool
+ anim util.Model
lastUserMessageTime time.Time
}
-
type MessageOption func(*messageCmp)
func WithLastUserMessageTime(t time.Time) MessageOption {
@@ -44,6 +48,7 @@ func WithLastUserMessageTime(t time.Time) MessageOption {
func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
m := &messageCmp{
message: msg,
+ anim: anim.New(15, ""),
}
for _, opt := range opts {
opt(m)
@@ -52,14 +57,23 @@ func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
}
func (m *messageCmp) Init() tea.Cmd {
+ m.spinning = m.shouldSpin()
+ if m.spinning {
+ return m.anim.Init()
+ }
return nil
}
func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- return m, nil
+ u, cmd := m.anim.Update(msg)
+ m.anim = u.(util.Model)
+ return m, cmd
}
func (m *messageCmp) View() string {
+ if m.spinning {
+ return m.style().PaddingLeft(1).Render(m.anim.View())
+ }
if m.message.ID != "" {
// this is a user or assistant message
switch m.message.Role {
@@ -72,6 +86,11 @@ func (m *messageCmp) View() string {
return "Unknown Message"
}
+// GetMessage implements MessageCmp.
+func (m *messageCmp) GetMessage() message.Message {
+ return m.message
+}
+
func (m *messageCmp) textWidth() int {
return m.width - 1 // take into account the border
}
@@ -184,6 +203,21 @@ func (m *messageCmp) markdownContent() string {
return m.toMarkdown(content)
}
+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
+}
+
// Blur implements MessageModel.
func (m *messageCmp) Blur() tea.Cmd {
m.focused = false
@@ -209,3 +243,8 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
m.width = width
return nil
}
+
+// Spinning implements MessageCmp.
+func (m *messageCmp) Spinning() bool {
+ return m.spinning
+}
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index 5547170e630142f07152db19f0371c6b816a5819..9bdca071a999a031e20fe568efde27e92862a82c 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -19,6 +19,8 @@ type ToolCallCmp interface {
util.Model
layout.Sizeable
layout.Focusable
+ GetToolCall() message.ToolCall
+ GetToolResult() message.ToolResult
}
type toolCallCmp struct {
@@ -73,14 +75,24 @@ func (m *toolCallCmp) View() string {
return box.PaddingLeft(1).Render(r.Render(m))
}
-func (v *toolCallCmp) renderPending() string {
- return fmt.Sprintf("%s: %s", prettifyToolName(v.call.Name), toolAction(v.call.Name))
+// GetToolCall implements ToolCallCmp.
+func (m *toolCallCmp) GetToolCall() message.ToolCall {
+ return m.call
}
-func (msg *toolCallCmp) style() lipgloss.Style {
+// GetToolResult implements ToolCallCmp.
+func (m *toolCallCmp) GetToolResult() message.ToolResult {
+ return m.result
+}
+
+func (m *toolCallCmp) renderPending() string {
+ return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), toolAction(m.call.Name))
+}
+
+func (m *toolCallCmp) style() lipgloss.Style {
t := theme.CurrentTheme()
borderStyle := lipgloss.NormalBorder()
- if msg.focused {
+ if m.focused {
borderStyle = lipgloss.DoubleBorder()
}
return styles.BaseStyle().
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
index 75e87335d27d83200e3b3ba9c39274bc658a4bc3..ce643d20076cd0f28c43dd18b10c47fea09facd9 100644
--- a/internal/tui/components/chat/sidebar.go
+++ b/internal/tui/components/chat/sidebar.go
@@ -93,11 +93,11 @@ func (m *sidebarCmp) View() string {
Render(
lipgloss.JoinVertical(
lipgloss.Top,
- header(m.width),
+ header(),
" ",
m.sessionSection(),
" ",
- lspsConfigured(m.width),
+ lspsConfigured(),
" ",
m.modifiedFiles(),
),
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index f8b0b4fe6a8f56d9cad4d414a581f93cdea01cb1..8b6ab7cf8ace8743a4164c1ca8de28d6d5058ad0 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -9,6 +9,8 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/logging"
+ "github.com/opencode-ai/opencode/internal/tui/components/anim"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -17,11 +19,17 @@ type ListModel interface {
util.Model
layout.Sizeable
SetItems([]util.Model) tea.Cmd
- AppendItem(util.Model)
- PrependItem(util.Model)
+ AppendItem(util.Model) tea.Cmd
+ PrependItem(util.Model) tea.Cmd
DeleteItem(int)
UpdateItem(int, util.Model)
ResetView()
+ Items() []util.Model
+}
+
+type HasAnim interface {
+ util.Model
+ Spinning() bool
}
type renderedItem struct {
@@ -107,6 +115,7 @@ func (m *model) Init() tea.Cmd {
// Update implements List.
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
@@ -151,11 +160,37 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.goToBottom()
return m, nil
}
+ case anim.ColorCycleMsg:
+ logging.Info("ColorCycleMsg", "msg", msg)
+ for inx, item := range m.items {
+ if i, ok := item.(HasAnim); ok {
+ if i.Spinning() {
+ updated, cmd := i.Update(msg)
+ cmds = append(cmds, cmd)
+ m.UpdateItem(inx, updated.(util.Model))
+ }
+ }
+ }
+ return m, tea.Batch(cmds...)
+ case anim.StepCharsMsg:
+ logging.Info("ColorCycleMsg", "msg", msg)
+ for inx, item := range m.items {
+ if i, ok := item.(HasAnim); ok {
+ if i.Spinning() {
+ updated, cmd := i.Update(msg)
+ cmds = append(cmds, cmd)
+ m.UpdateItem(inx, updated.(util.Model))
+ }
+ }
+ }
+ return m, tea.Batch(cmds...)
}
if m.selectedItemInx > -1 {
u, cmd := m.items[m.selectedItemInx].Update(msg)
+ cmds = append(cmds, cmd)
m.UpdateItem(m.selectedItemInx, u.(util.Model))
- return m, cmd
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
}
return m, nil
@@ -172,6 +207,11 @@ func (m *model) View() string {
return lipgloss.NewStyle().Padding(m.padding...).Height(m.height).Render(m.content)
}
+// Items implements ListModel.
+func (m *model) Items() []util.Model {
+ return m.items
+}
+
func (m *model) renderVisibleReverse() {
start := 0
cutoff := m.offset + m.listHeight()
@@ -464,7 +504,6 @@ func (m *model) rerenderItem(inx int) {
}
// TODO: if hight changed do something
if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
- panic("not handled")
}
m.renderedItems.Store(inx, renderedItem{
lines: rerenderedLines,
@@ -563,10 +602,12 @@ func (m *model) listHeight() int {
}
// AppendItem implements List.
-func (m *model) AppendItem(item util.Model) {
+func (m *model) AppendItem(item util.Model) tea.Cmd {
+ cmd := item.Init()
m.items = append(m.items, item)
m.goToBottom()
m.needsRerender = true
+ return cmd
}
// DeleteItem implements List.
@@ -577,7 +618,8 @@ func (m *model) DeleteItem(i int) {
}
// PrependItem implements List.
-func (m *model) PrependItem(item util.Model) {
+func (m *model) PrependItem(item util.Model) tea.Cmd {
+ cmd := item.Init()
m.items = append([]util.Model{item}, m.items...)
// update the indices of the rendered items
newRenderedItems := make(map[int]renderedItem)
@@ -594,6 +636,7 @@ func (m *model) PrependItem(item util.Model) {
}
m.goToTop()
m.needsRerender = true
+ return cmd
}
func (m *model) setReverse(reverse bool) {
@@ -610,6 +653,9 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
var cmds []tea.Cmd
cmd := m.setItemsSize()
cmds = append(cmds, cmd)
+ for _, item := range m.items {
+ cmds = append(cmds, item.Init())
+ }
if m.reverse {
m.selectedItemInx = len(m.items) - 1
cmd := m.focusSelected()
From 9c4a47b895c5022d75696fb66658bbfd74de3b6e Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Thu, 22 May 2025 17:43:48 +0200
Subject: [PATCH 06/73] fix tools calls
---
internal/tui/components/chat/editor.go | 4 +-
internal/tui/components/chat/list.go | 898 +++++++++++-----------
internal/tui/components/chat/list_v2.go | 8 +
internal/tui/components/chat/message.go | 629 ---------------
internal/tui/components/core/list/list.go | 15 +-
5 files changed, 476 insertions(+), 1078 deletions(-)
delete mode 100644 internal/tui/components/chat/message.go
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index 6dae5418d4ffeef5d4f29703b7bc45a580d9d958..7512bfc8ad775eb58accd9890c8a66d19adec3f8 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -211,7 +211,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.send()
}
}
-
}
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
@@ -233,7 +232,8 @@ func (m *editorCmp) View() string {
return lipgloss.JoinVertical(lipgloss.Top,
m.attachmentsContent(),
lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
- m.textarea.View()),
+ m.textarea.View(),
+ ),
)
}
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index 7bc3c4e81281c7ce1e41153315db89bdd08d79d3..95f0e4961519695168ce35e1db68e5958cb482e3 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -1,44 +1,49 @@
package chat
-import (
- "context"
- "fmt"
- "math"
-
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/spinner"
- "github.com/charmbracelet/bubbles/v2/viewport"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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{}
-
+import "github.com/charmbracelet/bubbles/v2/key"
+
+// import (
+//
+// "context"
+// "fmt"
+// "math"
+//
+// "github.com/charmbracelet/bubbles/v2/key"
+// "github.com/charmbracelet/bubbles/v2/spinner"
+// "github.com/charmbracelet/bubbles/v2/viewport"
+// tea "github.com/charmbracelet/bubbletea/v2"
+// "github.com/charmbracelet/lipgloss/v2"
+// "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
@@ -65,410 +70,411 @@ var messageKeys = MessageKeys{
),
}
-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(
- 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) 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.SetWidth(width)
- m.viewport.SetHeight(height - 2)
- m.attachments.SetWidth(width + 40)
- m.attachments.SetHeight(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) util.Model {
- s := spinner.New()
- s.Spinner = spinner.Pulse
- vp := viewport.New()
- attachmets := viewport.New()
- 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,
- }
-}
+//
+// 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(
+// 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) 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.SetWidth(width)
+// m.viewport.SetHeight(height - 2)
+// m.attachments.SetWidth(width + 40)
+// m.attachments.SetHeight(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) util.Model {
+// s := spinner.New()
+// s.Spinner = spinner.Pulse
+// vp := viewport.New()
+// attachmets := viewport.New()
+// 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/list_v2.go b/internal/tui/components/chat/list_v2.go
index d2ad3e4e95d0d53899b662037b14c75c27e221f2..cc3e33db8a814e382d94fa6b2db4bcc9bb935cc8 100644
--- a/internal/tui/components/chat/list_v2.go
+++ b/internal/tui/components/chat/list_v2.go
@@ -106,6 +106,8 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
return m.handleNewUserMessage(event.Payload)
case message.Assistant:
return m.handleNewAssistantMessage(event.Payload)
+ case message.Tool:
+ return m.handleToolMessage(event.Payload)
}
// TODO: handle tools
case pubsub.UpdatedEvent:
@@ -119,6 +121,10 @@ func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
}
+func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
+ return nil
+}
+
func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
// Simple update the content
items := m.listCmp.Items()
@@ -136,6 +142,8 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C
messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
),
)
+ } else if len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
+ m.listCmp.DeleteItem(len(items) - 1)
}
return nil
}
diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go
deleted file mode 100644
index fa96c54fb27af23341b425bfccc88d2bcdaa1322..0000000000000000000000000000000000000000
--- a/internal/tui/components/chat/message.go
+++ /dev/null
@@ -1,629 +0,0 @@
-package chat
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/charmbracelet/lipgloss/v2"
- "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{
- toMarkdown(msg, isFocused, width),
- }
-
- // 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..."
-}
-
-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 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 toMarkdown(resultContent, false, width)
- case tools.BashToolName:
- resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
- return toMarkdown(resultContent, true, width)
- 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 toMarkdown(resultContent, true, width)
- 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 toMarkdown(resultContent, true, width)
- 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 toMarkdown(resultContent, true, width)
- default:
- resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
- return toMarkdown(resultContent, true, width)
- }
-}
-
-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
-}
-
-func removeWorkingDirPrefix(path string) string {
- wd := config.WorkingDirectory()
- path = strings.TrimPrefix(path, wd)
- return path
-}
-
-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 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) - 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, "...")
-}
-
-// 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/core/list/list.go b/internal/tui/components/core/list/list.go
index 8b6ab7cf8ace8743a4164c1ca8de28d6d5058ad0..96abe76731a3c2941f56d68911df1048ffaf08ca 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -492,6 +492,7 @@ func (m *model) rerenderItem(inx int) {
}
// check if the item is in the content
start := cachedItem.start
+ logging.Info("rerenderItem", "inx", inx, "start", start, "cachedItem.start", cachedItem.start, "cachedItem.height", cachedItem.height)
end := start + cachedItem.height
totalLines := len(m.renderedLines)
if m.reverse {
@@ -504,6 +505,9 @@ func (m *model) rerenderItem(inx int) {
}
// TODO: if hight changed do something
if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
+ if inx == len(m.items)-1 {
+ m.finalHight = max(0, start+len(rerenderedLines)-m.listHeight())
+ }
}
m.renderedItems.Store(inx, renderedItem{
lines: rerenderedLines,
@@ -541,7 +545,12 @@ func (m *model) decreaseOffset(n int) {
// UpdateItem implements List.
func (m *model) UpdateItem(inx int, item util.Model) {
m.items[inx] = item
- m.rerenderItem(inx)
+ if m.selectedItemInx == inx {
+ if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+ i.Focus()
+ }
+ }
+ m.ResetView()
m.needsRerender = true
}
@@ -614,6 +623,10 @@ func (m *model) AppendItem(item util.Model) tea.Cmd {
func (m *model) DeleteItem(i int) {
m.items = slices.Delete(m.items, i, i+1)
m.renderedItems.Delete(i)
+ if m.selectedItemInx == i {
+ m.selectedItemInx--
+ }
+ m.ResetView()
m.needsRerender = true
}
From bc67c13e666dd1b812679b89e5daa90973a154e7 Mon Sep 17 00:00:00 2001
From: Ayman Bagabas
Date: Wed, 21 May 2025 12:04:15 -0400
Subject: [PATCH 07/73] fix: tui: use KeyPressMsg instead of KeyMsg
The `tea.KeyMsg` type can match both key presses and releases, which can
lead to unexpected behavior in the TUI. Use `tea.KeyPressMsg` explicitly
to ensure we match only key presses.
---
internal/format/spinner.go | 2 +-
internal/tui/components/chat/list.go | 451 -------------------
internal/tui/components/core/list/list.go | 2 +-
internal/tui/components/dialog/arguments.go | 2 +-
internal/tui/components/dialog/commands.go | 2 +-
internal/tui/components/dialog/complete.go | 2 +-
internal/tui/components/dialog/filepicker.go | 2 +-
internal/tui/components/dialog/init.go | 4 +-
internal/tui/components/dialog/models.go | 2 +-
internal/tui/components/dialog/permission.go | 2 +-
internal/tui/components/dialog/quit.go | 2 +-
internal/tui/components/dialog/session.go | 2 +-
internal/tui/components/dialog/theme.go | 2 +-
internal/tui/components/util/simple-list.go | 2 +-
internal/tui/page/chat.go | 4 +-
internal/tui/tui.go | 18 +-
16 files changed, 25 insertions(+), 476 deletions(-)
diff --git a/internal/format/spinner.go b/internal/format/spinner.go
index 89eb9d25cacd28584575711508ec5f89b7ef163c..739fac1b27c99b9c4c4da030943500eda79f957c 100644
--- a/internal/format/spinner.go
+++ b/internal/format/spinner.go
@@ -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/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index 95f0e4961519695168ce35e1db68e5958cb482e3..d8a266d3a6bb8da61453d31da2810b56d65d07f4 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -2,48 +2,6 @@ package chat
import "github.com/charmbracelet/bubbles/v2/key"
-// import (
-//
-// "context"
-// "fmt"
-// "math"
-//
-// "github.com/charmbracelet/bubbles/v2/key"
-// "github.com/charmbracelet/bubbles/v2/spinner"
-// "github.com/charmbracelet/bubbles/v2/viewport"
-// tea "github.com/charmbracelet/bubbletea/v2"
-// "github.com/charmbracelet/lipgloss/v2"
-// "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
@@ -69,412 +27,3 @@ var messageKeys = MessageKeys{
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(
-// 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) 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.SetWidth(width)
-// m.viewport.SetHeight(height - 2)
-// m.attachments.SetWidth(width + 40)
-// m.attachments.SetHeight(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) util.Model {
-// s := spinner.New()
-// s.Spinner = spinner.Pulse
-// vp := viewport.New()
-// attachmets := viewport.New()
-// 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/core/list/list.go b/internal/tui/components/core/list/list.go
index 96abe76731a3c2941f56d68911df1048ffaf08ca..3827fcdd7be95ea230bb432f677f559289415119 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -117,7 +117,7 @@ func (m *model) Init() tea.Cmd {
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
if m.reverse {
diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go
index a988407453322267c7cf96fa626b09f6b2ac36fd..109a389954b351c31e81b2e034c134202d4d7e0f 100644
--- a/internal/tui/components/dialog/arguments.go
+++ b/internal/tui/components/dialog/arguments.go
@@ -118,7 +118,7 @@ func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go
index 695f94da6191dcc5015b954e9890be37bb0d03fa..c89e8ffa1f2af7908651104cd021a2ea5ebed6c9 100644
--- a/internal/tui/components/dialog/commands.go
+++ b/internal/tui/components/dialog/commands.go
@@ -90,7 +90,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, commandKeys.Enter):
selectedItem, idx := c.listView.GetSelectedItem()
diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go
index cda4636be44ac637ffb8172015c7863c422fcde7..3031239882158d47da9d990df158b718de40ca37 100644
--- a/internal/tui/components/dialog/complete.go
+++ b/internal/tui/components/dialog/complete.go
@@ -136,7 +136,7 @@ func (c *completionDialogCmp) close() tea.Cmd {
func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
if c.pseudoSearchTextArea.Focused() {
if !key.Matches(msg, completionDialogKeys.Complete) {
diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go
index 2955c27514eb3eeb8cf7ec1c48a9b5e31d2a6c84..1b09d53a56542255fd83248cdc1b39ebbb2db24e 100644
--- a/internal/tui/components/dialog/filepicker.go
+++ b/internal/tui/components/dialog/filepicker.go
@@ -126,7 +126,7 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.viewport.SetHeight(22)
f.cursor = 0
f.getCurrentFileBelowCursor()
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
if f.cwd.Focused() {
f.cwd, cmd = f.cwd.Update(msg)
}
diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go
index 1224cbe10ad7dd54811ea1db4b16cddb718544a2..d4ef8c523f842ac979969596271b6538efe6af2b 100644
--- a/internal/tui/components/dialog/init.go
+++ b/internal/tui/components/dialog/init.go
@@ -70,7 +70,7 @@ func (m InitDialogCmp) Init() tea.Cmd {
// Update implements tea.Model.
func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
@@ -95,7 +95,7 @@ func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m InitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
-
+
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go
index c17f87cde3c5a3f066c7a5371fbff66f2a8c7d09..25b45839754bae91402e8a8cff529b55b9e6f6a6 100644
--- a/internal/tui/components/dialog/models.go
+++ b/internal/tui/components/dialog/models.go
@@ -111,7 +111,7 @@ func (m *modelDialogCmp) Init() tea.Cmd {
func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
m.moveSelectionUp()
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index 16c53266383d3cdfc1d5f8bda096b099b7bb170a..3db2ea2125e37a58e68a8f9eab8ee65257fb2c9a 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -107,7 +107,7 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
p.markdownCache = make(map[string]string)
p.diffCache = make(map[string]string)
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
p.selectedOption = (p.selectedOption + 1) % 3
diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go
index edb67f694f4161c39ae6e9ad471a7eb7e45cd65d..c1c7a5b1441eec5d2529f2aeafd4f068bd47711c 100644
--- a/internal/tui/components/dialog/quit.go
+++ b/internal/tui/components/dialog/quit.go
@@ -62,7 +62,7 @@ func (q *quitDialogCmp) Init() tea.Cmd {
func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
q.selectedNo = !q.selectedNo
diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go
index 60b5b8f360bc0383bde7e38ac4fa14ecf99d4fd1..8fc704711a8017241cc0093f1f7dd22f363c54af 100644
--- a/internal/tui/components/dialog/session.go
+++ b/internal/tui/components/dialog/session.go
@@ -77,7 +77,7 @@ func (s *sessionDialogCmp) Init() tea.Cmd {
func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
if s.selectedIdx > 0 {
diff --git a/internal/tui/components/dialog/theme.go b/internal/tui/components/dialog/theme.go
index 29e854d3d6b2db07a4aaaa3ebc218e4e6d1dfaf5..bdd89a9dd82dc31040a19027535b9ca914263124 100644
--- a/internal/tui/components/dialog/theme.go
+++ b/internal/tui/components/dialog/theme.go
@@ -86,7 +86,7 @@ func (t *themeDialogCmp) Init() tea.Cmd {
func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
if t.selectedIdx > 0 {
diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go
index 541bfe5b297049b1479f6834fa9d3cdcb292a488..a944925095a61bc247ff9534bcadff1a1609c01f 100644
--- a/internal/tui/components/util/simple-list.go
+++ b/internal/tui/components/util/simple-list.go
@@ -66,7 +66,7 @@ func (c *simpleListCmp[T]) Init() tea.Cmd {
func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
if c.selectedIdx > 0 {
diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go
index 37be69bdaf32f1bbb96a422350e16d681a6c7800..4546b268f9c3b9649ddd6b00f025feb8bf3d092a 100644
--- a/internal/tui/page/chat.go
+++ b/internal/tui/page/chat.go
@@ -102,7 +102,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
p.session = msg
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
switch {
case key.Matches(msg, keyMap.ShowCompletionDialog):
p.showCompletionDialog = true
@@ -128,7 +128,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, contextCmd)
// Doesn't forward event if enter key is pressed
- if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
if keyMsg.String() == "enter" {
return p, tea.Batch(cmds...)
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 3d4b54e790cdded0caa4ab1d3fb0863d28f51b94..b61782b9d0e8991e4ebc383a235e8418ed3b1e55 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -448,7 +448,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return a, nil
- case tea.KeyMsg:
+ case tea.KeyPressMsg:
// If multi-arguments dialog is open, let it handle the key press first
if a.showMultiArgumentsDialog {
args, cmd := a.multiArgumentsDialog.Update(msg)
@@ -588,7 +588,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.filepicker = f.(dialog.FilepickerCmp)
cmds = append(cmds, filepickerCmd)
// Only block key messages send all other messages down
- if _, ok := msg.(tea.KeyMsg); ok {
+ if _, ok := msg.(tea.KeyPressMsg); ok {
return a, tea.Batch(cmds...)
}
}
@@ -598,7 +598,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.quit = q.(dialog.QuitDialog)
cmds = append(cmds, quitCmd)
// Only block key messages send all other messages down
- if _, ok := msg.(tea.KeyMsg); ok {
+ if _, ok := msg.(tea.KeyPressMsg); ok {
return a, tea.Batch(cmds...)
}
}
@@ -607,7 +607,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.permissions = d.(dialog.PermissionDialogCmp)
cmds = append(cmds, permissionsCmd)
// Only block key messages send all other messages down
- if _, ok := msg.(tea.KeyMsg); ok {
+ if _, ok := msg.(tea.KeyPressMsg); ok {
return a, tea.Batch(cmds...)
}
}
@@ -617,7 +617,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.sessionDialog = d.(dialog.SessionDialog)
cmds = append(cmds, sessionCmd)
// Only block key messages send all other messages down
- if _, ok := msg.(tea.KeyMsg); ok {
+ if _, ok := msg.(tea.KeyPressMsg); ok {
return a, tea.Batch(cmds...)
}
}
@@ -627,7 +627,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.commandDialog = d.(dialog.CommandDialog)
cmds = append(cmds, commandCmd)
// Only block key messages send all other messages down
- if _, ok := msg.(tea.KeyMsg); ok {
+ if _, ok := msg.(tea.KeyPressMsg); ok {
return a, tea.Batch(cmds...)
}
}
@@ -637,7 +637,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.modelDialog = d.(dialog.ModelDialog)
cmds = append(cmds, modelCmd)
// Only block key messages send all other messages down
- if _, ok := msg.(tea.KeyMsg); ok {
+ if _, ok := msg.(tea.KeyPressMsg); ok {
return a, tea.Batch(cmds...)
}
}
@@ -647,7 +647,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.initDialog = d.(dialog.InitDialogCmp)
cmds = append(cmds, initCmd)
// Only block key messages send all other messages down
- if _, ok := msg.(tea.KeyMsg); ok {
+ if _, ok := msg.(tea.KeyPressMsg); ok {
return a, tea.Batch(cmds...)
}
}
@@ -657,7 +657,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.themeDialog = d.(dialog.ThemeDialog)
cmds = append(cmds, themeCmd)
// Only block key messages send all other messages down
- if _, ok := msg.(tea.KeyMsg); ok {
+ if _, ok := msg.(tea.KeyPressMsg); ok {
return a, tea.Batch(cmds...)
}
}
From a83fec2c7a3916125dfa4c8eca4e69e1a49a02e0 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Wed, 21 May 2025 16:25:55 -0300
Subject: [PATCH 08/73] chore: add a `.editorconfig` file
---
.editorconfig | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
create mode 100644 .editorconfig
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000000000000000000000000000000000000..5de2df8c5766460b43091d29af07636a58406434
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+# https://editorconfig.org/
+
+root = true
+
+[*]
+charset = utf-8
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 2
+
+[*.go]
+indent_style = tab
+indent_size = 8
+
+[*.golden]
+insert_final_newline = false
+trim_trailing_whitespace = false
From f6386bdbd38ef5689b3603200513a724d598eb52 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Wed, 21 May 2025 16:27:55 -0300
Subject: [PATCH 09/73] chore: add a taskfile and default golangci-lint config
---
.golangci.yml | 41 +++++++++++++++++++++++++++++++++++++++++
Taskfile.yaml | 19 +++++++++++++++++++
2 files changed, 60 insertions(+)
create mode 100644 .golangci.yml
create mode 100644 Taskfile.yaml
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..be61d89ba130fbb50371386e01017eab20854930
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,41 @@
+version: "2"
+run:
+ tests: false
+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
+ 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/Taskfile.yaml b/Taskfile.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..285b6bf3ebda1044ffdd2dbe8925e03b73b39d19
--- /dev/null
+++ b/Taskfile.yaml
@@ -0,0 +1,19 @@
+# https://taskfile.dev
+
+version: "3"
+
+tasks:
+ lint:
+ desc: Run base linters
+ cmds:
+ - golangci-lint run
+
+ test:
+ desc: Run tests
+ cmds:
+ - go test ./... {{.CLI_ARGS}}
+
+ fmt:
+ desc: Run gofumpt
+ cmds:
+ - gofumpt -w .
From 09274460dee8ac0619df4f2d921335e548ed5617 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Thu, 22 May 2025 17:31:16 -0300
Subject: [PATCH 10/73] chore: run `gofumpt`
---
internal/app/lsp.go | 10 +--
internal/config/init.go | 1 -
internal/llm/prompt/prompt_test.go | 6 +-
internal/llm/provider/azure.go | 1 -
internal/llm/provider/bedrock.go | 1 -
internal/llm/tools/ls_test.go | 54 +++++++-------
internal/llm/tools/shell/shell.go | 8 +--
.../tui/components/dialog/custom_commands.go | 3 +-
.../components/dialog/custom_commands_test.go | 20 +++---
internal/tui/theme/dracula.go | 2 +-
internal/tui/theme/flexoki.go | 70 +++++++++----------
internal/tui/theme/gruvbox.go | 12 ++--
internal/tui/theme/monokai.go | 2 +-
internal/tui/theme/onedark.go | 2 +-
internal/tui/theme/theme_test.go | 32 ++++-----
internal/tui/theme/tokyonight.go | 12 ++--
internal/tui/theme/tron.go | 2 +-
17 files changed, 117 insertions(+), 121 deletions(-)
diff --git a/internal/app/lsp.go b/internal/app/lsp.go
index 872532fd80aa6d99adc0e34ee1ecf25de34df253..c04cc42398423d88e9277ce57cddc54ebcc8a66a 100644
--- a/internal/app/lsp.go
+++ b/internal/app/lsp.go
@@ -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/config/init.go b/internal/config/init.go
index e0a1c6da7372fb3c66656d18bdf565357b6b1b07..5f8860f5264aaf0002ad782595f505c0e881b049 100644
--- a/internal/config/init.go
+++ b/internal/config/init.go
@@ -58,4 +58,3 @@ func MarkProjectInitialized() error {
return nil
}
-
diff --git a/internal/llm/prompt/prompt_test.go b/internal/llm/prompt/prompt_test.go
index 405ad5194b85c208c52749cecc5ca9f84b05c614..bcd9e20993a4e0b4555d9dc82e46330938223b72 100644
--- a/internal/llm/prompt/prompt_test.go
+++ b/internal/llm/prompt/prompt_test.go
@@ -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/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..9fa3ca87f984147a3137fa013484b453d37d9687 100644
--- a/internal/llm/provider/bedrock.go
+++ b/internal/llm/provider/bedrock.go
@@ -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/tools/ls_test.go b/internal/llm/tools/ls_test.go
index 508cb98d36aee05ed3ea2417ed10ee364298ba27..98c97ed95b5db4bbb0ee5f21ba5ee646a43de889 100644
--- a/internal/llm/tools/ls_test.go
+++ b/internal/llm/tools/ls_test.go
@@ -56,14 +56,14 @@ func TestLsTool_Run(t *testing.T) {
// Create directories
for _, dir := range testDirs {
dirPath := filepath.Join(tempDir, dir)
- err := os.MkdirAll(dirPath, 0755)
+ err := os.MkdirAll(dirPath, 0o755)
require.NoError(t, err)
}
// Create files
for _, file := range testFiles {
filePath := filepath.Join(tempDir, file)
- err := os.WriteFile(filePath, []byte("test content"), 0644)
+ err := os.WriteFile(filePath, []byte("test content"), 0o644)
require.NoError(t, err)
}
@@ -83,19 +83,19 @@ func TestLsTool_Run(t *testing.T) {
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__")
})
@@ -122,7 +122,7 @@ func TestLsTool_Run(t *testing.T) {
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: "",
@@ -138,7 +138,7 @@ func TestLsTool_Run(t *testing.T) {
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)
@@ -173,11 +173,11 @@ func TestLsTool_Run(t *testing.T) {
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/")
})
@@ -189,12 +189,12 @@ func TestLsTool_Run(t *testing.T) {
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),
@@ -210,7 +210,7 @@ func TestLsTool_Run(t *testing.T) {
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")
@@ -291,22 +291,22 @@ func TestCreateFileTree(t *testing.T) {
}
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 {
@@ -315,7 +315,7 @@ func TestCreateFileTree(t *testing.T) {
break
}
}
-
+
require.NotNil(t, dir1Node)
assert.Equal(t, "directory", dir1Node.Type)
assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir
@@ -354,9 +354,9 @@ func TestPrintTree(t *testing.T) {
Type: "file",
},
}
-
+
result := printTree(tree, "/root")
-
+
// Check the output format
assert.Contains(t, result, "- /root/")
assert.Contains(t, result, " - dir1/")
@@ -390,14 +390,14 @@ func TestListDirectory(t *testing.T) {
// Create directories
for _, dir := range testDirs {
dirPath := filepath.Join(tempDir, dir)
- err := os.MkdirAll(dirPath, 0755)
+ err := os.MkdirAll(dirPath, 0o755)
require.NoError(t, err)
}
// Create files
for _, file := range testFiles {
filePath := filepath.Join(tempDir, file)
- err := os.WriteFile(filePath, []byte("test content"), 0644)
+ err := os.WriteFile(filePath, []byte("test content"), 0o644)
require.NoError(t, err)
}
@@ -405,7 +405,7 @@ func TestListDirectory(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)
@@ -416,12 +416,12 @@ func TestListDirectory(t *testing.T) {
}
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"))
@@ -438,12 +438,12 @@ func TestListDirectory(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 {
@@ -454,4 +454,4 @@ func TestListDirectory(t *testing.T) {
}
assert.True(t, containsDir)
})
-}
\ No newline at end of file
+}
diff --git a/internal/llm/tools/shell/shell.go b/internal/llm/tools/shell/shell.go
index 7d3b87e4b2f3145a7e7a157a0c31e83232048ccd..cc127cd0cab5cde909e8c1fe9760c4bbefd57f8f 100644
--- a/internal/llm/tools/shell/shell.go
+++ b/internal/llm/tools/shell/shell.go
@@ -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"}
diff --git a/internal/tui/components/dialog/custom_commands.go b/internal/tui/components/dialog/custom_commands.go
index cd1ed3988ea10ff35400f90e427b9b054e6348cd..dd2ae57148ee07ef1a88087d93525a4f439bdc54 100644
--- a/internal/tui/components/dialog/custom_commands.go
+++ b/internal/tui/components/dialog/custom_commands.go
@@ -82,7 +82,7 @@ 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 {
+ if err := os.MkdirAll(commandsDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
}
// Return empty list since we just created the directory
@@ -171,7 +171,6 @@ func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
commands = append(commands, command)
return nil
})
-
if err != nil {
return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
}
diff --git a/internal/tui/components/dialog/custom_commands_test.go b/internal/tui/components/dialog/custom_commands_test.go
index 3468ac3b0b2c5acc8999fcf3b444411e7f07ca5c..c21eaaa548adc563b6dc4c75125c588c9782b061 100644
--- a/internal/tui/components/dialog/custom_commands_test.go
+++ b/internal/tui/components/dialog/custom_commands_test.go
@@ -1,8 +1,8 @@
package dialog
import (
- "testing"
"regexp"
+ "testing"
)
func TestNamedArgPattern(t *testing.T) {
@@ -38,11 +38,11 @@ func TestNamedArgPattern(t *testing.T) {
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] {
@@ -50,13 +50,13 @@ func TestNamedArgPattern(t *testing.T) {
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
@@ -75,7 +75,7 @@ func TestNamedArgPattern(t *testing.T) {
func TestRegexPattern(t *testing.T) {
pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
-
+
validMatches := []string{
"$FOO",
"$BAR",
@@ -83,7 +83,7 @@ func TestRegexPattern(t *testing.T) {
"$BAZ123",
"$ARGUMENTS",
}
-
+
invalidMatches := []string{
"$foo",
"$1BAR",
@@ -91,16 +91,16 @@ func TestRegexPattern(t *testing.T) {
"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/theme/dracula.go b/internal/tui/theme/dracula.go
index eaf981c786e20f605bdeb7ac90c1e3f955421f59..10a1a7216107bff40415e3f56b66f49d6840035f 100644
--- a/internal/tui/theme/dracula.go
+++ b/internal/tui/theme/dracula.go
@@ -103,4 +103,4 @@ func NewDraculaTheme() *DraculaTheme {
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
index fc7f59b81ee056ef0ef771b2132f181388036a4a..183cd65d0de26e7a511dde5f18a98caf523a0941 100644
--- a/internal/tui/theme/flexoki.go
+++ b/internal/tui/theme/flexoki.go
@@ -7,20 +7,20 @@ import (
// 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)
+ 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"
@@ -86,11 +86,11 @@ func NewFlexokiDarkTheme() *FlexokiTheme {
theme.DiffHunkHeaderColor = lipgloss.Color(flexokiBase700)
theme.DiffHighlightAddedColor = lipgloss.Color(flexokiGreen400)
theme.DiffHighlightRemovedColor = lipgloss.Color(flexokiRed400)
- theme.DiffAddedBgColor = lipgloss.Color("#1D2419") // Darker green background
+ theme.DiffAddedBgColor = lipgloss.Color("#1D2419") // Darker green background
theme.DiffRemovedBgColor = lipgloss.Color("#241919") // Darker red background
theme.DiffContextBgColor = lipgloss.Color(flexokiBlack)
theme.DiffLineNumberColor = lipgloss.Color(flexokiBase700)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#1A2017") // Slightly darker green
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#1A2017") // Slightly darker green
theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#201717") // Slightly darker red
// Markdown colors
@@ -110,14 +110,14 @@ func NewFlexokiDarkTheme() *FlexokiTheme {
theme.MarkdownCodeBlockColor = lipgloss.Color(flexokiBase300)
// Syntax highlighting colors (based on Flexoki's mappings)
- theme.SyntaxCommentColor = lipgloss.Color(flexokiBase700) // tx-3
- theme.SyntaxKeywordColor = lipgloss.Color(flexokiGreen400) // gr
- theme.SyntaxFunctionColor = lipgloss.Color(flexokiOrange400) // or
- theme.SyntaxVariableColor = lipgloss.Color(flexokiBlue400) // bl
- theme.SyntaxStringColor = lipgloss.Color(flexokiCyan400) // cy
- theme.SyntaxNumberColor = lipgloss.Color(flexokiPurple400) // pu
- theme.SyntaxTypeColor = lipgloss.Color(flexokiYellow400) // ye
- theme.SyntaxOperatorColor = lipgloss.Color(flexokiBase500) // tx-2
+ theme.SyntaxCommentColor = lipgloss.Color(flexokiBase700) // tx-3
+ theme.SyntaxKeywordColor = lipgloss.Color(flexokiGreen400) // gr
+ theme.SyntaxFunctionColor = lipgloss.Color(flexokiOrange400) // or
+ theme.SyntaxVariableColor = lipgloss.Color(flexokiBlue400) // bl
+ theme.SyntaxStringColor = lipgloss.Color(flexokiCyan400) // cy
+ theme.SyntaxNumberColor = lipgloss.Color(flexokiPurple400) // pu
+ theme.SyntaxTypeColor = lipgloss.Color(flexokiYellow400) // ye
+ theme.SyntaxOperatorColor = lipgloss.Color(flexokiBase500) // tx-2
theme.SyntaxPunctuationColor = lipgloss.Color(flexokiBase500) // tx-2
return theme
@@ -160,11 +160,11 @@ func NewFlexokiLightTheme() *FlexokiTheme {
theme.DiffHunkHeaderColor = lipgloss.Color(flexokiBase500)
theme.DiffHighlightAddedColor = lipgloss.Color(flexokiGreen600)
theme.DiffHighlightRemovedColor = lipgloss.Color(flexokiRed600)
- theme.DiffAddedBgColor = lipgloss.Color("#EFF2E2") // Light green background
+ theme.DiffAddedBgColor = lipgloss.Color("#EFF2E2") // Light green background
theme.DiffRemovedBgColor = lipgloss.Color("#F2E2E2") // Light red background
theme.DiffContextBgColor = lipgloss.Color(flexokiPaper)
theme.DiffLineNumberColor = lipgloss.Color(flexokiBase500)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#E5EBD9") // Light green
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#E5EBD9") // Light green
theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#EBD9D9") // Light red
// Markdown colors
@@ -184,14 +184,14 @@ func NewFlexokiLightTheme() *FlexokiTheme {
theme.MarkdownCodeBlockColor = lipgloss.Color(flexokiBase600)
// Syntax highlighting colors (based on Flexoki's mappings)
- theme.SyntaxCommentColor = lipgloss.Color(flexokiBase300) // tx-3
- theme.SyntaxKeywordColor = lipgloss.Color(flexokiGreen600) // gr
- theme.SyntaxFunctionColor = lipgloss.Color(flexokiOrange600) // or
- theme.SyntaxVariableColor = lipgloss.Color(flexokiBlue600) // bl
- theme.SyntaxStringColor = lipgloss.Color(flexokiCyan600) // cy
- theme.SyntaxNumberColor = lipgloss.Color(flexokiPurple600) // pu
- theme.SyntaxTypeColor = lipgloss.Color(flexokiYellow600) // ye
- theme.SyntaxOperatorColor = lipgloss.Color(flexokiBase500) // tx-2
+ theme.SyntaxCommentColor = lipgloss.Color(flexokiBase300) // tx-3
+ theme.SyntaxKeywordColor = lipgloss.Color(flexokiGreen600) // gr
+ theme.SyntaxFunctionColor = lipgloss.Color(flexokiOrange600) // or
+ theme.SyntaxVariableColor = lipgloss.Color(flexokiBlue600) // bl
+ theme.SyntaxStringColor = lipgloss.Color(flexokiCyan600) // cy
+ theme.SyntaxNumberColor = lipgloss.Color(flexokiPurple600) // pu
+ theme.SyntaxTypeColor = lipgloss.Color(flexokiYellow600) // ye
+ theme.SyntaxOperatorColor = lipgloss.Color(flexokiBase500) // tx-2
theme.SyntaxPunctuationColor = lipgloss.Color(flexokiBase500) // tx-2
return theme
@@ -201,4 +201,4 @@ func init() {
// Register the Flexoki themes with the theme manager
RegisterTheme("flexoki-dark", NewFlexokiDarkTheme())
RegisterTheme("flexoki-light", NewFlexokiLightTheme())
-}
\ No newline at end of file
+}
diff --git a/internal/tui/theme/gruvbox.go b/internal/tui/theme/gruvbox.go
index 6df6ebb4d5723052d419476f5c9b9397e1bafb3d..0eb79b44da2d8c4d039e0073a2e755a6372f03fa 100644
--- a/internal/tui/theme/gruvbox.go
+++ b/internal/tui/theme/gruvbox.go
@@ -106,12 +106,12 @@ func NewGruvboxTheme() *GruvboxTheme {
theme.DiffHunkHeaderColor = lipgloss.Color(gruvboxDarkFg3)
theme.DiffHighlightAddedColor = lipgloss.Color(gruvboxDarkGreenBright)
theme.DiffHighlightRemovedColor = lipgloss.Color(gruvboxDarkRedBright)
- theme.DiffAddedBgColor = lipgloss.Color("#3C4C3C") // Darker green background
- theme.DiffRemovedBgColor = lipgloss.Color("#4C3C3C") // Darker red background
+ theme.DiffAddedBgColor = lipgloss.Color("#3C4C3C") // Darker green background
+ theme.DiffRemovedBgColor = lipgloss.Color("#4C3C3C") // Darker red background
theme.DiffContextBgColor = lipgloss.Color(gruvboxDarkBg0)
theme.DiffLineNumberColor = lipgloss.Color(gruvboxDarkFg4)
theme.DiffAddedLineNumberBgColor = lipgloss.Color("#32432F") // Slightly darker green
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#43322F") // Slightly darker red
+ theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#43322F") // Slightly darker red
// Markdown colors
theme.MarkdownTextColor = lipgloss.Color(gruvboxDarkFg1)
@@ -180,11 +180,11 @@ func NewGruvboxLightTheme() *GruvboxTheme {
theme.DiffHunkHeaderColor = lipgloss.Color(gruvboxLightFg3)
theme.DiffHighlightAddedColor = lipgloss.Color(gruvboxLightGreenBright)
theme.DiffHighlightRemovedColor = lipgloss.Color(gruvboxLightRedBright)
- theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9") // Light green background
+ theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9") // Light green background
theme.DiffRemovedBgColor = lipgloss.Color("#FFEBEE") // Light red background
theme.DiffContextBgColor = lipgloss.Color(gruvboxLightBg0)
theme.DiffLineNumberColor = lipgloss.Color(gruvboxLightFg4)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9") // Light green
+ theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9") // Light green
theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#FFCDD2") // Light red
// Markdown colors
@@ -221,4 +221,4 @@ func init() {
// Register the Gruvbox themes with the theme manager
RegisterTheme("gruvbox", NewGruvboxTheme())
RegisterTheme("gruvbox-light", NewGruvboxLightTheme())
-}
\ No newline at end of file
+}
diff --git a/internal/tui/theme/monokai.go b/internal/tui/theme/monokai.go
index 8b860316dc649310f6b976369c16ded9a932f3d9..abe342906d156da8128df30599a0056b525e05e2 100644
--- a/internal/tui/theme/monokai.go
+++ b/internal/tui/theme/monokai.go
@@ -192,4 +192,4 @@ func init() {
// Register the Monokai Pro themes with the theme manager
RegisterTheme("monokai", NewMonokaiProTheme())
RegisterTheme("monokai-light", NewMonokaiProLightTheme())
-}
\ No newline at end of file
+}
diff --git a/internal/tui/theme/onedark.go b/internal/tui/theme/onedark.go
index 936998d98142e73de4396e3ccbf320061fc2289e..5c694b2837f0c4a86ab4d5b85705847c739c1f4d 100644
--- a/internal/tui/theme/onedark.go
+++ b/internal/tui/theme/onedark.go
@@ -193,4 +193,4 @@ func init() {
// Register the One Dark and One Light themes with the theme manager
RegisterTheme("onedark", NewOneDarkTheme())
RegisterTheme("onelight", NewOneLightTheme())
-}
\ No newline at end of file
+}
diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go
index 5ec810e3377ebfeb1a1ef6d0b399e6baefd0e403..790ee3aa8a37a3561da92ab56431f12646d050ec 100644
--- a/internal/tui/theme/theme_test.go
+++ b/internal/tui/theme/theme_test.go
@@ -7,7 +7,7 @@ import (
func TestThemeRegistration(t *testing.T) {
// Get list of available themes
availableThemes := AvailableThemes()
-
+
// Check if "catppuccin" theme is registered
catppuccinFound := false
for _, themeName := range availableThemes {
@@ -16,11 +16,11 @@ func TestThemeRegistration(t *testing.T) {
break
}
}
-
+
if !catppuccinFound {
t.Errorf("Catppuccin theme is not registered")
}
-
+
// Check if "gruvbox" theme is registered
gruvboxFound := false
for _, themeName := range availableThemes {
@@ -29,11 +29,11 @@ func TestThemeRegistration(t *testing.T) {
break
}
}
-
+
if !gruvboxFound {
t.Errorf("Gruvbox theme is not registered")
}
-
+
// Check if "monokai" theme is registered
monokaiFound := false
for _, themeName := range availableThemes {
@@ -42,48 +42,48 @@ func TestThemeRegistration(t *testing.T) {
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
index 61d82140cda3e3cc9d09f4b6d05b8459dca2cbc5..36fd976c3d9e69ffaa7d5b5203e7f7ad4d8a15dc 100644
--- a/internal/tui/theme/tokyonight.go
+++ b/internal/tui/theme/tokyonight.go
@@ -57,11 +57,11 @@ func NewTokyoNightTheme() *TokyoNightTheme {
theme.BorderDimColor = lipgloss.Color(darkSelection)
// Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#4fd6be") // teal from palette
- theme.DiffRemovedColor = lipgloss.Color("#c53b53") // red1 from palette
- theme.DiffContextColor = lipgloss.Color("#828bb8") // fg_dark from palette
- theme.DiffHunkHeaderColor = lipgloss.Color("#828bb8") // fg_dark from palette
- theme.DiffHighlightAddedColor = lipgloss.Color("#b8db87") // git.add from palette
+ theme.DiffAddedColor = lipgloss.Color("#4fd6be") // teal from palette
+ theme.DiffRemovedColor = lipgloss.Color("#c53b53") // red1 from palette
+ theme.DiffContextColor = lipgloss.Color("#828bb8") // fg_dark from palette
+ theme.DiffHunkHeaderColor = lipgloss.Color("#828bb8") // fg_dark from palette
+ theme.DiffHighlightAddedColor = lipgloss.Color("#b8db87") // git.add from palette
theme.DiffHighlightRemovedColor = lipgloss.Color("#e26a75") // git.delete from palette
theme.DiffAddedBgColor = lipgloss.Color("#20303b")
theme.DiffRemovedBgColor = lipgloss.Color("#37222c")
@@ -193,4 +193,4 @@ func init() {
// Register the Tokyo Night themes with the theme manager
RegisterTheme("tokyonight", NewTokyoNightTheme())
RegisterTheme("tokyonight-day", NewTokyoNightDayTheme())
-}
\ No newline at end of file
+}
diff --git a/internal/tui/theme/tron.go b/internal/tui/theme/tron.go
index 9b55dd1e2cf46404dcacd0e7e84f4d0e78dd16ce..9e08f88c9a04e7e617f12434d8a233e2791a4b1e 100644
--- a/internal/tui/theme/tron.go
+++ b/internal/tui/theme/tron.go
@@ -195,4 +195,4 @@ func init() {
// Register the Tron themes with the theme manager
RegisterTheme("tron", NewTronTheme())
RegisterTheme("tron-light", NewTronLightTheme())
-}
\ No newline at end of file
+}
From 474c085101b45c34573cd92e7199381dde6ac0c5 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Wed, 21 May 2025 16:43:31 -0300
Subject: [PATCH 11/73] lint: fix whitespace issues
---
internal/diff/diff.go | 5 +----
internal/llm/provider/gemini.go | 2 --
internal/lsp/watcher/watcher.go | 1 -
internal/message/message.go | 1 -
internal/tui/components/dialog/complete.go | 2 --
internal/tui/tui.go | 3 ---
6 files changed, 1 insertion(+), 13 deletions(-)
diff --git a/internal/diff/diff.go b/internal/diff/diff.go
index 9e5e8ae43a31f7df945befca3f505563d0e67919..589d17f232f92f73d28900c1b5bc606ee8a6f822 100644
--- a/internal/diff/diff.go
+++ b/internal/diff/diff.go
@@ -232,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]
diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go
index ebc3611994045ab8bf1bc00c37ab5e24416998a3..96cf02a8b311bb5fa536394452fe9cb05713faaa 100644
--- a/internal/llm/provider/gemini.go
+++ b/internal/llm/provider/gemini.go
@@ -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/lsp/watcher/watcher.go b/internal/lsp/watcher/watcher.go
index fd7e04837185edafe2372c0f926ec55f8ec95001..1b68dc68df719d2128bfb1fe04028115a14e51b0 100644
--- a/internal/lsp/watcher/watcher.go
+++ b/internal/lsp/watcher/watcher.go
@@ -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/message.go b/internal/message/message.go
index 9c58ef202fa248804558dd0ecd027782a772edb6..6e0fd40b4946a709cf10dc55d1d422447c03a23f 100644
--- a/internal/message/message.go
+++ b/internal/message/message.go
@@ -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/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go
index 3031239882158d47da9d990df158b718de40ca37..4fbd6e3eee9338ca79f02f90e3e111cbf1339541 100644
--- a/internal/tui/components/dialog/complete.go
+++ b/internal/tui/components/dialog/complete.go
@@ -138,9 +138,7 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
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)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index b61782b9d0e8991e4ebc383a235e8418ed3b1e55..94b8c958bfec4d546ccd642cea3ecc515d5c7931 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -457,7 +457,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch {
-
case key.Matches(msg, keys.Quit):
a.showQuit = !a.showQuit
if a.showHelp {
@@ -580,7 +579,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f, filepickerCmd := a.filepicker.Update(msg)
a.filepicker = f.(dialog.FilepickerCmp)
cmds = append(cmds, filepickerCmd)
-
}
if a.showFilepicker {
@@ -733,7 +731,6 @@ func (a appModel) View() string {
appView,
true,
)
-
}
// Show compacting status overlay
From b76edd40ef9bbc54264b728a1ef438bb40765586 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Wed, 21 May 2025 16:44:20 -0300
Subject: [PATCH 12/73] lint: fix unneeded lint comment
---
internal/tui/components/chat/editor.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index 7512bfc8ad775eb58accd9890c8a66d19adec3f8..b6c67a426d2cb6a58f3fe50e55c011c1953086fc 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -90,7 +90,7 @@ func (m *editorCmp) openEditor() tea.Cmd {
return util.ReportError(err)
}
tmpfile.Close()
- c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
+ c := exec.Command(editor, tmpfile.Name())
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
From 0f17256efaf845c496285779a4573b969bab2099 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Wed, 21 May 2025 16:49:24 -0300
Subject: [PATCH 13/73] lint: temporarily disable the broken rules
---
.golangci.yml | 31 ++++++++++++++++++-------------
1 file changed, 18 insertions(+), 13 deletions(-)
diff --git a/.golangci.yml b/.golangci.yml
index be61d89ba130fbb50371386e01017eab20854930..6e343851f7b819055641d35a455a1d6467c1632d 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -4,28 +4,33 @@ run:
linters:
enable:
- bodyclose
- - exhaustive
- - goconst
- - godot
- - godox
+ # - exhaustive
+ # - goconst
+ # - godot
+ # - godox
- gomoddirectives
- goprintffuncname
- - gosec
+ # - gosec
- misspell
- - nakedret
- - nestif
- - nilerr
+ # - nakedret
+ # - nestif
+ # - nilerr
- noctx
- nolintlint
- - prealloc
- - revive
+ # - prealloc
+ # - revive
- rowserrcheck
- sqlclosecheck
- tparallel
- - unconvert
- - unparam
+ # - unconvert
+ # - unparam
- whitespace
- - wrapcheck
+ # - wrapcheck
+ disable:
+ - errcheck
+ - ineffassign
+ - staticcheck
+ - unused
exclusions:
generated: lax
presets:
From 26d9141f3824e375d1458734f9b08953c2fa58ec Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Wed, 21 May 2025 16:51:17 -0300
Subject: [PATCH 14/73] lint: setup to find issues on tests as well
---
.golangci.yml | 2 --
1 file changed, 2 deletions(-)
diff --git a/.golangci.yml b/.golangci.yml
index 6e343851f7b819055641d35a455a1d6467c1632d..f1cc201a6202e8777242b9768fe05635e1ab08d3 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,6 +1,4 @@
version: "2"
-run:
- tests: false
linters:
enable:
- bodyclose
From 4b5ea7413ba71aeac6f0bc71d37ece73b1dbb009 Mon Sep 17 00:00:00 2001
From: Andrey Nering
Date: Wed, 21 May 2025 16:52:21 -0300
Subject: [PATCH 15/73] ci: run linting on ci
---
.github/workflows/lint.yml | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 .github/workflows/lint.yml
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
From 288325689ea359b22d83587cb0e8f82b7505d398 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Fri, 23 May 2025 14:04:34 +0200
Subject: [PATCH 16/73] implement tool calls in the ui
---
.editorconfig | 2 +-
internal/llm/agent/agent.go | 14 +-
internal/llm/prompt/coder.go | 4 +-
internal/llm/provider/anthropic.go | 2 +-
internal/tui/components/anim/anim.go | 38 +++--
internal/tui/components/chat/list_v2.go | 84 ++++++++---
.../tui/components/chat/messages/messages.go | 13 +-
.../tui/components/chat/messages/renderer.go | 29 ----
internal/tui/components/chat/messages/tool.go | 107 +++++++++-----
internal/tui/components/core/list/list.go | 138 +++++++++++-------
internal/tui/tui.go | 1 -
11 files changed, 269 insertions(+), 163 deletions(-)
diff --git a/.editorconfig b/.editorconfig
index 5de2df8c5766460b43091d29af07636a58406434..0407ebbcbf7483988728000ce19928a0ffc3cdf6 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -11,7 +11,7 @@ indent_size = 2
[*.go]
indent_style = tab
-indent_size = 8
+indent_size = 4
[*.golden]
insert_final_newline = false
diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go
index 4f31fe75d688aa2c4fdd80a4f633fe35d45125cc..511cf62996bd6e0d506a428344f34d89e515c82a 100644
--- a/internal/llm/agent/agent.go
+++ b/internal/llm/agent/agent.go
@@ -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:
diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go
index 4cfa1314e0faab0d914e49949cbac01d80f8389c..495f2406a435fec54cfea9ac4abffd4e839c28e8 100644
--- a/internal/llm/prompt/coder.go
+++ b/internal/llm/prompt/coder.go
@@ -153,7 +153,7 @@ 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.
@@ -162,7 +162,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
# 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/provider/anthropic.go b/internal/llm/provider/anthropic.go
index badf6a3a07df27f6494bdbf9692f174e0a17a1ce..4b558e2fb18fe411e1dfbbc3652a2246375a9929 100644
--- a/internal/llm/provider/anthropic.go
+++ b/internal/llm/provider/anthropic.go
@@ -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,
},
}
}
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index aed03d946d97a7e59a8fb4d08ba8a0c2bd30ffad..91ae8317eafaa6c49fce54194b8f1013d88042f4 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/google/uuid"
"github.com/lucasb-eyer/go-colorful"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -54,19 +55,23 @@ func (c cyclingChar) state(start time.Time) charState {
return charCyclingState
}
-type StepCharsMsg struct{}
+type StepCharsMsg struct {
+ id string
+}
-func stepChars() tea.Cmd {
+func stepChars(id string) tea.Cmd {
return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
- return StepCharsMsg{}
+ return StepCharsMsg{id}
})
}
-type ColorCycleMsg struct{}
+type ColorCycleMsg struct {
+ id string
+}
-func cycleColors() tea.Cmd {
+func cycleColors(id string) tea.Cmd {
return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
- return ColorCycleMsg{}
+ return ColorCycleMsg{id}
})
}
@@ -80,6 +85,7 @@ type anim struct {
label []rune
ellipsis spinner.Model
ellipsisStarted bool
+ id string
}
func New(cyclingCharsSize uint, label string) util.Model {
@@ -91,10 +97,12 @@ func New(cyclingCharsSize uint, label string) util.Model {
gap = ""
}
+ id := uuid.New()
c := anim{
start: time.Now(),
label: []rune(gap + label),
ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
+ id: id.String(),
}
// If we're in truecolor mode (and there are enough cycling characters)
@@ -144,15 +152,18 @@ func New(cyclingCharsSize uint, label string) util.Model {
}
// Init initializes the animation.
-func (anim) Init() tea.Cmd {
- return tea.Batch(stepChars(), cycleColors())
+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.(type) {
+ switch msg := msg.(type) {
case StepCharsMsg:
+ if msg.id != a.id {
+ return a, nil
+ }
a.updateChars(&a.cyclingChars)
a.updateChars(&a.labelChars)
@@ -173,14 +184,17 @@ func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
- return a, tea.Batch(stepChars(), cmd)
+ 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()
+ return a, cycleColors(a.id)
case spinner.TickMsg:
var cmd tea.Cmd
a.ellipsis, cmd = a.ellipsis.Update(msg)
@@ -216,7 +230,7 @@ func (a anim) View() string {
b.WriteRune(c.currentValue)
}
- if len(a.label) > 1 {
+ if len(a.labelChars) > 1 {
textStyle := styles.BaseStyle().
Foreground(t.Text())
for _, c := range a.labelChars {
diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go
index cc3e33db8a814e382d94fa6b2db4bcc9bb935cc8..52efd9b0b818a45ec1c045f0024cb35633a192e5 100644
--- a/internal/tui/components/chat/list_v2.go
+++ b/internal/tui/components/chat/list_v2.go
@@ -7,6 +7,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"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/pubsub"
"github.com/opencode-ai/opencode/internal/session"
@@ -61,7 +62,8 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.listCmp.SetItems([]util.Model{})
case pubsub.Event[message.Message]:
- return m, m.handleMessageEvent(msg)
+ cmd := m.handleMessageEvent(msg)
+ return m, cmd
default:
var cmds []tea.Cmd
u, cmd := m.listCmp.Update(msg)
@@ -92,8 +94,8 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
// more likely to be at the end of the list
items := m.listCmp.Items()
for i := len(items) - 1; i >= 0; i-- {
- msg := items[i].(messages.MessageCmp)
- if msg.GetMessage().ID == event.Payload.ID {
+ msg, ok := items[i].(messages.MessageCmp)
+ if ok && msg.GetMessage().ID == event.Payload.ID {
messageExists = true
break
}
@@ -109,7 +111,6 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
case message.Tool:
return m.handleToolMessage(event.Payload)
}
- // TODO: handle tools
case pubsub.UpdatedEvent:
return m.handleUpdateAssistantMessage(event.Payload)
}
@@ -122,30 +123,79 @@ func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
}
func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
+ items := m.listCmp.Items()
+ for _, tr := range msg.ToolResults() {
+ for i := len(items) - 1; i >= 0; i-- {
+ message := items[i]
+ if toolCall, ok := message.(messages.ToolCallCmp); ok {
+ if toolCall.GetToolCall().ID == tr.ToolCallID {
+ toolCall.SetToolResult(tr)
+ m.listCmp.UpdateItem(
+ i,
+ toolCall,
+ )
+ break
+ }
+ }
+ }
+ }
return nil
}
func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
+ var cmds []tea.Cmd
// Simple update the content
items := m.listCmp.Items()
- lastItem := items[len(items)-1].(messages.MessageCmp)
- // TODO:handle tool calls
- if lastItem.GetMessage().ID != msg.ID {
- return nil
+ assistantMessageInx := -1
+ toolCalls := map[int]messages.ToolCallCmp{}
+
+ // we go backwards because the messages are most likely at the end of the list
+ for i := len(items) - 1; i >= 0; i-- {
+ message := items[i]
+ if asMsg, ok := message.(messages.MessageCmp); ok {
+ if asMsg.GetMessage().ID == msg.ID {
+ assistantMessageInx = i
+ }
+ } else if tc, ok := message.(messages.ToolCallCmp); ok {
+ if tc.ParentMessageId() == msg.ID {
+ toolCalls[i] = tc
+ }
+ }
}
- // for now just updet the last message
- if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
+
+ logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantMessageInx, "toolCalls", toolCalls)
+
+ if assistantMessageInx > -1 && (len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()) {
m.listCmp.UpdateItem(
- len(items)-1,
+ assistantMessageInx,
messages.NewMessageCmp(
msg,
messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
),
)
- } else if len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
- m.listCmp.DeleteItem(len(items) - 1)
+ } else if assistantMessageInx > -1 && len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
+ m.listCmp.DeleteItem(assistantMessageInx)
}
- return nil
+ for _, tc := range msg.ToolCalls() {
+ found := false
+ for inx, tcc := range toolCalls {
+ if tc.ID == tcc.GetToolCall().ID {
+ tcc.SetToolCall(tc)
+ m.listCmp.UpdateItem(
+ inx,
+ tcc,
+ )
+ found = true
+ break
+ }
+ }
+ if !found {
+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
+ cmds = append(cmds, cmd)
+ }
+ }
+
+ return tea.Batch(cmds...)
}
func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
@@ -161,7 +211,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
cmds = append(cmds, cmd)
}
for _, tc := range msg.ToolCalls() {
- cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(tc))
+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
@@ -206,10 +256,10 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
if tr, ok := toolResultMap[tc.ID]; ok {
options = append(options, messages.WithToolCallResult(tr))
}
- if msg.FinishPart().Reason == message.FinishReasonCanceled {
+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
options = append(options, messages.WithToolCallCancelled())
}
- uiMessages = append(uiMessages, messages.NewToolCallCmp(tc, options...))
+ uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
}
}
}
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index 3ae278a496df5ee60472172bc761bd07e43d8662..ede75252a0723674c3af6d98774438cd20fbf80f 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -65,9 +65,16 @@ func (m *messageCmp) Init() tea.Cmd {
}
func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- u, cmd := m.anim.Update(msg)
- m.anim = u.(util.Model)
- return m, cmd
+ switch msg := msg.(type) {
+ case anim.ColorCycleMsg, anim.StepCharsMsg:
+ m.spinning = m.shouldSpin()
+ if m.spinning {
+ u, cmd := m.anim.Update(msg)
+ m.anim = u.(util.Model)
+ return m, cmd
+ }
+ }
+ return m, nil
}
func (m *messageCmp) View() string {
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index ebea3eff09461fa87a44c0789e39dff232e023d7..28360830538ae717d0cb14761e04b72407fddc1a 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -524,32 +524,3 @@ func prettifyToolName(name string) string {
return name
}
}
-
-func toolAction(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..."
- default:
- return "Working..."
- }
-}
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index 9bdca071a999a031e20fe568efde27e92862a82c..d51f659262b83ff6a7ec6cc70c3c4eda189fe987 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -6,9 +6,9 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
- "github.com/opencode-ai/opencode/internal/llm/agent"
- "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/tui/components/anim"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -21,15 +21,24 @@ type ToolCallCmp interface {
layout.Focusable
GetToolCall() message.ToolCall
GetToolResult() message.ToolResult
+ SetToolResult(message.ToolResult)
+ SetToolCall(message.ToolCall)
+ SetCancelled()
+ ParentMessageId() string
+ Spinning() bool
}
type toolCallCmp struct {
width int
focused bool
- call message.ToolCall
- result message.ToolResult
- cancelled bool
+ parentMessageId string
+ call message.ToolCall
+ result message.ToolResult
+ cancelled bool
+
+ spinning bool
+ anim util.Model
}
type ToolCallOption func(*toolCallCmp)
@@ -46,9 +55,11 @@ func WithToolCallResult(result message.ToolResult) ToolCallOption {
}
}
-func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
+func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
m := &toolCallCmp{
- call: tc,
+ call: tc,
+ parentMessageId: parentMessageId,
+ anim: anim.New(15, "Working"),
}
for _, opt := range opts {
opt(m)
@@ -57,10 +68,24 @@ func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
}
func (m *toolCallCmp) Init() tea.Cmd {
+ m.spinning = m.shouldSpin()
+ logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
+ if m.spinning {
+ return m.anim.Init()
+ }
return nil
}
func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ logging.Debug("Tool call update", "msg", msg)
+ switch msg := msg.(type) {
+ case anim.ColorCycleMsg, anim.StepCharsMsg:
+ if m.spinning {
+ u, cmd := m.anim.Update(msg)
+ m.anim = u.(util.Model)
+ return m, cmd
+ }
+ }
return m, nil
}
@@ -75,6 +100,30 @@ func (m *toolCallCmp) View() string {
return box.PaddingLeft(1).Render(r.Render(m))
}
+// SetCancelled implements ToolCallCmp.
+func (m *toolCallCmp) SetCancelled() {
+ m.cancelled = true
+}
+
+// SetToolCall implements ToolCallCmp.
+func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
+ m.call = call
+ if m.call.Finished {
+ m.spinning = false
+ }
+}
+
+// ParentMessageId implements ToolCallCmp.
+func (m *toolCallCmp) ParentMessageId() string {
+ return m.parentMessageId
+}
+
+// SetToolResult implements ToolCallCmp.
+func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
+ m.result = result
+ m.spinning = false
+}
+
// GetToolCall implements ToolCallCmp.
func (m *toolCallCmp) GetToolCall() message.ToolCall {
return m.call
@@ -86,7 +135,7 @@ func (m *toolCallCmp) GetToolResult() message.ToolResult {
}
func (m *toolCallCmp) renderPending() string {
- return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), toolAction(m.call.Name))
+ return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
}
func (m *toolCallCmp) style() lipgloss.Style {
@@ -113,35 +162,6 @@ func (m *toolCallCmp) fit(content string, width int) string {
return ansi.Truncate(content, width, dots)
}
-func (m *toolCallCmp) toolName() string {
- switch m.call.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 m.call.Name
- }
-}
-
func (m *toolCallCmp) Blur() tea.Cmd {
m.focused = false
return nil
@@ -165,3 +185,16 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
m.width = width
return nil
}
+
+func (m *toolCallCmp) shouldSpin() bool {
+ if !m.call.Finished {
+ return true
+ } else if m.result.ToolCallID != m.call.ID {
+ return true
+ }
+ return false
+}
+
+func (m *toolCallCmp) Spinning() bool {
+ return m.spinning
+}
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 3827fcdd7be95ea230bb432f677f559289415119..628b6835f83ec17d0a853889043290c7244a5006 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -9,7 +9,6 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/tui/components/anim"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/util"
@@ -39,7 +38,7 @@ type renderedItem struct {
}
type model struct {
width, height, offset int
- finalHight int // this gets set when the last item is rendered to mark the max offset
+ finalHeight int // this gets set when the last item is rendered to mark the max offset
reverse bool
help help.Model
keymap KeyMap
@@ -95,7 +94,7 @@ func New(opts ...listOptions) ListModel {
gapSize: 0,
padding: []int{},
selectedItemInx: -1,
- finalHight: -1,
+ finalHeight: -1,
lastRenderedInx: -1,
renderedItems: new(sync.Map),
}
@@ -160,20 +159,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.goToBottom()
return m, nil
}
- case anim.ColorCycleMsg:
- logging.Info("ColorCycleMsg", "msg", msg)
- for inx, item := range m.items {
- if i, ok := item.(HasAnim); ok {
- if i.Spinning() {
- updated, cmd := i.Update(msg)
- cmds = append(cmds, cmd)
- m.UpdateItem(inx, updated.(util.Model))
- }
- }
- }
- return m, tea.Batch(cmds...)
- case anim.StepCharsMsg:
- logging.Info("ColorCycleMsg", "msg", msg)
+ case anim.ColorCycleMsg, anim.StepCharsMsg:
for inx, item := range m.items {
if i, ok := item.(HasAnim); ok {
if i.Spinning() {
@@ -189,7 +175,6 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
u, cmd := m.items[m.selectedItemInx].Update(msg)
cmds = append(cmds, cmd)
m.UpdateItem(m.selectedItemInx, u.(util.Model))
- cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
@@ -232,7 +217,7 @@ func (m *model) renderVisibleReverse() {
itemLines = cachedContent.(renderedItem).lines
} else {
itemLines = strings.Split(items[i].View(), "\n")
- if m.gapSize > 0 && realIndex != len(m.items)-1 {
+ if m.gapSize > 0 {
for range m.gapSize {
itemLines = append(itemLines, "")
}
@@ -245,7 +230,7 @@ func (m *model) renderVisibleReverse() {
}
if realIndex == 0 {
- m.finalHight = max(0, start+len(itemLines)-m.listHeight())
+ m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
}
m.renderedLines = append(itemLines, m.renderedLines...)
m.lastRenderedInx = realIndex
@@ -256,9 +241,9 @@ func (m *model) renderVisibleReverse() {
start += len(itemLines)
}
m.needsRerender = false
- if m.finalHight > -1 {
+ if m.finalHeight > -1 {
// make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
- m.offset = min(m.offset, m.finalHight)
+ m.offset = min(m.offset, m.finalHeight)
}
maxHeight := min(m.listHeight(), len(m.renderedLines))
if m.offset < len(m.renderedLines) {
@@ -293,7 +278,7 @@ func (m *model) renderVisible() {
itemLines = cachedContent.(renderedItem).lines
} else {
itemLines = strings.Split(item.View(), "\n")
- if m.gapSize > 0 && realIndex != len(m.items)-1 {
+ if m.gapSize > 0 {
for range m.gapSize {
itemLines = append(itemLines, "")
}
@@ -310,7 +295,7 @@ func (m *model) renderVisible() {
}
if realIndex == len(m.items)-1 {
- m.finalHight = max(0, start+len(itemLines)-m.listHeight())
+ m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
}
m.renderedLines = append(m.renderedLines, itemLines...)
@@ -319,9 +304,9 @@ func (m *model) renderVisible() {
}
m.needsRerender = false
maxHeight := min(m.listHeight(), len(m.renderedLines))
- if m.finalHight > -1 {
+ if m.finalHeight > -1 {
// make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
- m.offset = min(m.offset, m.finalHight)
+ m.offset = min(m.offset, m.finalHeight)
}
if m.offset < len(m.renderedLines) {
m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
@@ -412,6 +397,9 @@ func (m *model) downOneItem() tea.Cmd {
}
func (m *model) goToBottom() tea.Cmd {
+ if len(m.items) == 0 {
+ return nil
+ }
var cmds []tea.Cmd
m.reverse = true
cmd := m.blurSelected()
@@ -428,11 +416,14 @@ func (m *model) ResetView() {
m.renderedLines = []string{}
m.offset = 0
m.lastRenderedInx = -1
- m.finalHight = -1
+ m.finalHeight = -1
m.needsRerender = true
}
func (m *model) goToTop() tea.Cmd {
+ if len(m.items) == 0 {
+ return nil
+ }
var cmds []tea.Cmd
m.reverse = false
cmd := m.blurSelected()
@@ -480,7 +471,7 @@ func (m *model) rerenderItem(inx int) {
}
rerenderedItem := m.items[inx].View()
rerenderedLines := strings.Split(rerenderedItem, "\n")
- if m.gapSize > 0 && inx != len(m.items)-1 {
+ if m.gapSize > 0 {
for range m.gapSize {
rerenderedLines = append(rerenderedLines, "")
}
@@ -492,7 +483,6 @@ func (m *model) rerenderItem(inx int) {
}
// check if the item is in the content
start := cachedItem.start
- logging.Info("rerenderItem", "inx", inx, "start", start, "cachedItem.start", cachedItem.start, "cachedItem.height", cachedItem.height)
end := start + cachedItem.height
totalLines := len(m.renderedLines)
if m.reverse {
@@ -504,9 +494,35 @@ func (m *model) rerenderItem(inx int) {
m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
}
// TODO: if hight changed do something
- if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
+ if cachedItem.height != len(rerenderedLines) {
if inx == len(m.items)-1 {
- m.finalHight = max(0, start+len(rerenderedLines)-m.listHeight())
+ m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
+ }
+
+ // update the start of the other cached items
+ currentStart := cachedItem.start + len(rerenderedLines)
+ if m.reverse {
+ for i := inx - 1; i < len(m.items); i-- {
+ if existing, ok := m.renderedItems.Load(i); ok {
+ cached := existing.(renderedItem)
+ cached.start = currentStart
+ currentStart += cached.height
+ m.renderedItems.Store(i, cached)
+ } else {
+ break
+ }
+ }
+ } else {
+ for i := inx + 1; i < len(m.items); i++ {
+ if existing, ok := m.renderedItems.Load(i); ok {
+ cached := existing.(renderedItem)
+ cached.start = currentStart
+ currentStart += cached.height
+ m.renderedItems.Store(i, cached)
+ } else {
+ break
+ }
+ }
}
}
m.renderedItems.Store(inx, renderedItem{
@@ -518,11 +534,11 @@ func (m *model) rerenderItem(inx int) {
}
func (m *model) increaseOffset(n int) {
- if m.finalHight > -1 {
- if m.offset < m.finalHight {
+ if m.finalHeight > -1 {
+ if m.offset < m.finalHeight {
m.offset += n
- if m.offset > m.finalHight {
- m.offset = m.finalHight
+ if m.offset > m.finalHeight {
+ m.offset = m.finalHeight
}
m.needsRerender = true
}
@@ -550,7 +566,8 @@ func (m *model) UpdateItem(inx int, item util.Model) {
i.Focus()
}
}
- m.ResetView()
+ m.setItemSize(inx)
+ m.rerenderItem(inx)
m.needsRerender = true
}
@@ -565,16 +582,15 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
return nil
}
if m.height != height {
- m.finalHight = -1
+ m.finalHeight = -1
m.height = height
}
m.width = width
m.ResetView()
- return m.setItemsSize()
+ return m.setAllItemsSize()
}
-func (m *model) setItemsSize() tea.Cmd {
- var cmds []tea.Cmd
+func (m *model) getItemSize() int {
width := m.width
if m.padding != nil {
if len(m.padding) == 1 {
@@ -585,11 +601,22 @@ func (m *model) setItemsSize() tea.Cmd {
width -= m.padding[1] + m.padding[3]
}
}
- for _, item := range m.items {
- if i, ok := item.(layout.Sizeable); ok {
- cmd := i.SetSize(width, 0) // height is not limited
- cmds = append(cmds, cmd)
- }
+ return width
+}
+
+func (m *model) setItemSize(inx int) tea.Cmd {
+ if i, ok := m.items[inx].(layout.Sizeable); ok {
+ cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
+ return cmd
+ }
+ return nil
+}
+
+func (m *model) setAllItemsSize() tea.Cmd {
+ var cmds []tea.Cmd
+ for i := range m.items {
+ cmd := m.setItemSize(i)
+ cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
@@ -612,11 +639,16 @@ func (m *model) listHeight() int {
// AppendItem implements List.
func (m *model) AppendItem(item util.Model) tea.Cmd {
+ var cmds []tea.Cmd
cmd := item.Init()
+ cmds = append(cmds, cmd)
m.items = append(m.items, item)
- m.goToBottom()
+ cmd = m.setItemSize(len(m.items) - 1)
+ cmds = append(cmds, cmd)
+ cmd = m.goToBottom()
+ cmds = append(cmds, cmd)
m.needsRerender = true
- return cmd
+ return tea.Batch(cmds...)
}
// DeleteItem implements List.
@@ -632,7 +664,9 @@ func (m *model) DeleteItem(i int) {
// PrependItem implements List.
func (m *model) PrependItem(item util.Model) tea.Cmd {
+ var cmds []tea.Cmd
cmd := item.Init()
+ cmds = append(cmds, cmd)
m.items = append([]util.Model{item}, m.items...)
// update the indices of the rendered items
newRenderedItems := make(map[int]renderedItem)
@@ -647,9 +681,12 @@ func (m *model) PrependItem(item util.Model) tea.Cmd {
for k, v := range newRenderedItems {
m.renderedItems.Store(k, v)
}
- m.goToTop()
+ cmd = m.goToTop()
+ cmds = append(cmds, cmd)
+ cmd = m.setItemSize(0)
+ cmds = append(cmds, cmd)
m.needsRerender = true
- return cmd
+ return tea.Batch(cmds...)
}
func (m *model) setReverse(reverse bool) {
@@ -664,7 +701,7 @@ func (m *model) setReverse(reverse bool) {
func (m *model) SetItems(items []util.Model) tea.Cmd {
m.items = items
var cmds []tea.Cmd
- cmd := m.setItemsSize()
+ cmd := m.setAllItemsSize()
cmds = append(cmds, cmd)
for _, item := range m.items {
cmds = append(cmds, item.Init())
@@ -678,7 +715,6 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
cmd := m.focusSelected()
cmds = append(cmds, cmd)
}
- m.needsRerender = true
m.ResetView()
return tea.Batch(cmds...)
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 94b8c958bfec4d546ccd642cea3ecc515d5c7931..75f90d97b6c4c8117fcd28bbb3f2cfdf96934777 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -186,7 +186,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
- logging.Info("Window size changed main: ", "Width", msg.Width, "Height", msg.Height)
msg.Height -= 1 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
From 8c6e81791e84bbc53214fde8baa012f2ddb8a752 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Fri, 23 May 2025 17:17:39 +0200
Subject: [PATCH 17/73] refactor and document
---
.editorconfig | 18 -
OpenCode.md | 22 +
internal/tui/components/chat/editor.go | 4 -
internal/tui/components/chat/list.go | 447 ++++++-
internal/tui/components/chat/list_v2.go | 279 ----
.../tui/components/chat/messages/messages.go | 71 +-
.../tui/components/chat/messages/renderer.go | 447 ++++---
internal/tui/components/chat/messages/tool.go | 92 +-
internal/tui/components/core/list/keys.go | 2 +-
internal/tui/components/core/list/list.go | 1120 ++++++++++-------
10 files changed, 1513 insertions(+), 989 deletions(-)
delete mode 100644 .editorconfig
create mode 100644 OpenCode.md
delete mode 100644 internal/tui/components/chat/list_v2.go
diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
index 0407ebbcbf7483988728000ce19928a0ffc3cdf6..0000000000000000000000000000000000000000
--- a/.editorconfig
+++ /dev/null
@@ -1,18 +0,0 @@
-# https://editorconfig.org/
-
-root = true
-
-[*]
-charset = utf-8
-insert_final_newline = true
-trim_trailing_whitespace = true
-indent_style = space
-indent_size = 2
-
-[*.go]
-indent_style = tab
-indent_size = 4
-
-[*.golden]
-insert_final_newline = false
-trim_trailing_whitespace = false
diff --git a/OpenCode.md b/OpenCode.md
new file mode 100644
index 0000000000000000000000000000000000000000..f55de8ccd00bc58596ad01e1c4b3549e9e82bf93
--- /dev/null
+++ b/OpenCode.md
@@ -0,0 +1,22 @@
+# OpenCode Development Guide
+
+## Build/Test/Lint Commands
+
+- **Build**: `go build ./...` or `go build .` (for main binary)
+- **Test**: `task test` or `go test ./...`
+- **Single test**: `go test ./internal/path/to/package -run TestName`
+- **Lint**: `task lint` or `golangci-lint run`
+- **Format**: `task fmt` or `gofumpt -w .`
+
+## Code Style Guidelines
+
+- **Imports**: Standard library first, then third-party, then internal packages (separated by blank lines)
+- **Types**: Use `any` instead of `interface{}`, prefer concrete types over interfaces when possible
+- **Naming**: Use camelCase for private, PascalCase for public, descriptive names (e.g., `messageListCmp`, `handleNewUserMessage`)
+- **Constants**: Use `const` blocks with descriptive names (e.g., `NotFound = -1`)
+- **Error handling**: Always check errors, use `require.NoError()` in tests, return errors up the stack
+- **Documentation**: Add comments for all public types/methods, explain complex logic in private methods
+- **Testing**: Use testify/assert and testify/require, table-driven tests with `t.Run()`, mark helpers with `t.Helper()`
+- **File organization**: Group related functionality, extract helper methods for complex logic, use meaningful method names
+- **TUI components**: Implement interfaces (util.Model, layout.Sizeable), document component purpose and behavior
+- **Message handling**: Use pubsub events, handle different message roles (User/Assistant/Tool), manage tool calls separately
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index b6c67a426d2cb6a58f3fe50e55c011c1953086fc..339b6dd23bad94849dcbf0d2df7aab341a16d295 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -185,10 +185,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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...")
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index d8a266d3a6bb8da61453d31da2810b56d65d07f4..b9b43590361a43627f33880bc97d4e7c65badfa1 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -1,29 +1,424 @@
package chat
-import "github.com/charmbracelet/bubbles/v2/key"
-
-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"),
- ),
+import (
+ "context"
+ "time"
+
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "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/pubsub"
+ "github.com/opencode-ai/opencode/internal/session"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
+ "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"
+)
+
+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
+}
+
+// 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
+
+ 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()
+ defaultKeymaps.NDown.SetEnabled(false)
+ defaultKeymaps.NUp.SetEnabled(false)
+ defaultKeymaps.Home = key.NewBinding(
+ key.WithKeys("ctrl+g"),
+ )
+ defaultKeymaps.End = key.NewBinding(
+ key.WithKeys("ctrl+G"),
+ )
+ return &messageListCmp{
+ app: app,
+ listCmp: list.New(
+ list.WithGapSize(1),
+ list.WithReverse(true),
+ list.WithKeyMap(defaultKeymaps),
+ ),
+ }
+}
+
+// Init initializes the component (no initialization needed).
+func (m *messageListCmp) Init() tea.Cmd {
+ return nil
+}
+
+// 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 dialog.ThemeChangedMsg:
+ m.listCmp.ResetView()
+ 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{}
+ return m, m.listCmp.SetItems([]util.Model{})
+
+ 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() string {
+ if len(m.listCmp.Items()) == 0 {
+ return initialScreen()
+ }
+ return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
+}
+
+// handleChildSession handles messages from child sessions (agent tools).
+// TODO: update the agent tool message with the changes
+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
+ // Implementation pending
+}
+
+// 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 {
+ m.handleChildSession(event)
+ return nil
+ }
+
+ if m.messageExists(event.Payload.ID) {
+ return nil
+ }
+
+ return m.handleNewMessage(event.Payload)
+ case pubsub.UpdatedEvent:
+ 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)
+
+ logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantIndex, "toolCalls", existingToolCalls)
+
+ // 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
+ }
+ }
+
+ // 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...)
+}
+
+// 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...))
+ }
+
+ return uiMessages
+}
+
+// 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 options
+}
+
+// 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)
}
diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go
deleted file mode 100644
index 52efd9b0b818a45ec1c045f0024cb35633a192e5..0000000000000000000000000000000000000000
--- a/internal/tui/components/chat/list_v2.go
+++ /dev/null
@@ -1,279 +0,0 @@
-package chat
-
-import (
- "context"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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/pubsub"
- "github.com/opencode-ai/opencode/internal/session"
- "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
- "github.com/opencode-ai/opencode/internal/tui/components/core/list"
- "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"
-)
-
-type MessageListCmp interface {
- util.Model
- layout.Sizeable
-}
-
-type messageListCmp struct {
- app *app.App
- width, height int
- session session.Session
- listCmp list.ListModel
-
- lastUserMessageTime int64
-}
-
-func NewMessagesListCmp(app *app.App) MessageListCmp {
- return &messageListCmp{
- app: app,
- listCmp: list.New(
- list.WithGapSize(1),
- list.WithReverse(true),
- ),
- }
-}
-
-func (m *messageListCmp) Init() tea.Cmd {
- return nil
-}
-
-func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case dialog.ThemeChangedMsg:
- m.listCmp.ResetView()
- 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{}
- return m, m.listCmp.SetItems([]util.Model{})
-
- 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...)
- }
-}
-
-func (m *messageListCmp) View() string {
- if len(m.listCmp.Items()) == 0 {
- return initialScreen()
- }
- return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
-}
-
-func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
- // TODO: update the agent tool message with the changes
-}
-
-func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
- switch event.Type {
- case pubsub.CreatedEvent:
- if event.Payload.SessionID != m.session.ID {
- m.handleChildSession(event)
- }
- messageExists := false
- // more likely to be at the end of the list
- items := m.listCmp.Items()
- for i := len(items) - 1; i >= 0; i-- {
- msg, ok := items[i].(messages.MessageCmp)
- if ok && msg.GetMessage().ID == event.Payload.ID {
- messageExists = true
- break
- }
- }
- if messageExists {
- return nil
- }
- switch event.Payload.Role {
- case message.User:
- return m.handleNewUserMessage(event.Payload)
- case message.Assistant:
- return m.handleNewAssistantMessage(event.Payload)
- case message.Tool:
- return m.handleToolMessage(event.Payload)
- }
- case pubsub.UpdatedEvent:
- return m.handleUpdateAssistantMessage(event.Payload)
- }
- return nil
-}
-
-func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
- m.lastUserMessageTime = msg.CreatedAt
- return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
-}
-
-func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
- items := m.listCmp.Items()
- for _, tr := range msg.ToolResults() {
- for i := len(items) - 1; i >= 0; i-- {
- message := items[i]
- if toolCall, ok := message.(messages.ToolCallCmp); ok {
- if toolCall.GetToolCall().ID == tr.ToolCallID {
- toolCall.SetToolResult(tr)
- m.listCmp.UpdateItem(
- i,
- toolCall,
- )
- break
- }
- }
- }
- }
- return nil
-}
-
-func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
- var cmds []tea.Cmd
- // Simple update the content
- items := m.listCmp.Items()
- assistantMessageInx := -1
- toolCalls := map[int]messages.ToolCallCmp{}
-
- // we go backwards because the messages are most likely at the end of the list
- for i := len(items) - 1; i >= 0; i-- {
- message := items[i]
- if asMsg, ok := message.(messages.MessageCmp); ok {
- if asMsg.GetMessage().ID == msg.ID {
- assistantMessageInx = i
- }
- } else if tc, ok := message.(messages.ToolCallCmp); ok {
- if tc.ParentMessageId() == msg.ID {
- toolCalls[i] = tc
- }
- }
- }
-
- logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantMessageInx, "toolCalls", toolCalls)
-
- if assistantMessageInx > -1 && (len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()) {
- m.listCmp.UpdateItem(
- assistantMessageInx,
- messages.NewMessageCmp(
- msg,
- messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
- ),
- )
- } else if assistantMessageInx > -1 && len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
- m.listCmp.DeleteItem(assistantMessageInx)
- }
- for _, tc := range msg.ToolCalls() {
- found := false
- for inx, tcc := range toolCalls {
- if tc.ID == tcc.GetToolCall().ID {
- tcc.SetToolCall(tc)
- m.listCmp.UpdateItem(
- inx,
- tcc,
- )
- found = true
- break
- }
- }
- if !found {
- cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
- cmds = append(cmds, cmd)
- }
- }
-
- return tea.Batch(cmds...)
-}
-
-func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
- var cmds []tea.Cmd
- // Only add assistant messages if they don't have tool calls or there is some content
- if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
- cmd := m.listCmp.AppendItem(
- messages.NewMessageCmp(
- msg,
- messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
- ),
- )
- cmds = append(cmds, cmd)
- }
- for _, tc := range msg.ToolCalls() {
- cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
- cmds = append(cmds, cmd)
- }
- return tea.Batch(cmds...)
-}
-
-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)
- }
- uiMessages := make([]util.Model, 0)
- m.lastUserMessageTime = sessionMessages[0].CreatedAt
- toolResultMap := make(map[string]message.ToolResult)
- // first pass to get all tool results
- for _, msg := range sessionMessages {
- for _, tr := range msg.ToolResults() {
- toolResultMap[tr.ToolCallID] = tr
- }
- }
- for _, msg := range sessionMessages {
- switch msg.Role {
- case message.User:
- m.lastUserMessageTime = msg.CreatedAt
- uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
- case message.Assistant:
- // Only add assistant messages if they don't have tool calls or there is some content
- if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
- uiMessages = append(
- uiMessages,
- messages.NewMessageCmp(
- msg,
- messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
- ),
- )
- }
- for _, tc := range msg.ToolCalls() {
- options := []messages.ToolCallOption{}
- if tr, ok := toolResultMap[tc.ID]; ok {
- options = append(options, messages.WithToolCallResult(tr))
- }
- if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
- options = append(options, messages.WithToolCallCancelled())
- }
- uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
- }
- }
- }
- return m.listCmp.SetItems(uiMessages)
-}
-
-// GetSize implements MessageListCmp.
-func (m *messageListCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-// SetSize implements MessageListCmp.
-func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
- m.width = width
- m.height = height - 1
- return m.listCmp.SetSize(width, height-1)
-}
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index ede75252a0723674c3af6d98774438cd20fbf80f..714f79b3a46b2147bc51c115e0ac6b9ee4482f4d 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -19,32 +19,42 @@ import (
"github.com/opencode-ai/opencode/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
- layout.Sizeable
- layout.Focusable
- GetMessage() message.Message
- Spinning() bool
+ 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
- focused bool
-
- // Used for agent and user messages
- message message.Message
- spinning bool
- anim util.Model
- lastUserMessageTime time.Time
+ 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,
@@ -56,6 +66,8 @@ func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
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 {
@@ -64,6 +76,8 @@ func (m *messageCmp) Init() tea.Cmd {
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:
@@ -77,6 +91,8 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.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() string {
if m.spinning {
return m.style().PaddingLeft(1).Render(m.anim.View())
@@ -93,15 +109,19 @@ func (m *messageCmp) View() string {
return "Unknown Message"
}
-// GetMessage implements MessageCmp.
+// 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 - 1 // take into account the border
}
+// 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 := theme.CurrentTheme()
var borderColor color.Color
@@ -127,6 +147,8 @@ func (msg *messageCmp) style() lipgloss.Style {
BorderStyle(borderStyle)
}
+// 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 {
parts := []string{
m.markdownContent(),
@@ -156,6 +178,8 @@ func (m *messageCmp) renderAssistantMessage() string {
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 := theme.CurrentTheme()
parts := []string{
@@ -183,12 +207,15 @@ func (m *messageCmp) renderUserMessage() string {
return m.style().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 {
@@ -210,6 +237,8 @@ func (m *messageCmp) markdownContent() string {
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
@@ -225,33 +254,39 @@ func (m *messageCmp) shouldSpin() bool {
return true
}
-// Blur implements MessageModel.
+// Focus management methods
+
+// Blur removes focus from the message component
func (m *messageCmp) Blur() tea.Cmd {
m.focused = false
return nil
}
-// Focus implements MessageModel.
+// Focus sets focus on the message component
func (m *messageCmp) Focus() tea.Cmd {
m.focused = true
return nil
}
-// IsFocused implements MessageModel.
+// 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 {
m.width = width
return nil
}
-// Spinning implements MessageCmp.
+// 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
index 28360830538ae717d0cb14761e04b72407fddc1a..b8a7b834543f33be69336eb3bef00493ed95d84c 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -17,19 +17,26 @@ import (
"github.com/opencode-ai/opencode/internal/tui/theme"
)
+// 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()
@@ -37,9 +44,67 @@ func (rr renderRegistry) lookup(name string) renderer {
return genericRenderer{} // sensible fallback
}
+// registry holds all registered tool renderers
var registry = renderRegistry{}
-// Registger tool renderers
+// 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 {
+ header := makeHeader(toolName, v.textWidth(), args...)
+ if res, done := earlyState(header, v); done {
+ return res
+ }
+ body := contentRenderer()
+ return joinHeaderBody(header, body)
+}
+
+// unmarshalParams safely unmarshals JSON parameters
+func (br baseRenderer) unmarshalParams(input string, target any) error {
+ return json.Unmarshal([]byte(input), target)
+}
+
+// Register tool renderers
func init() {
registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
@@ -58,304 +123,358 @@ func init() {
// Generic renderer
// -----------------------------------------------------------------------------
-type genericRenderer struct{}
+// genericRenderer handles unknown tool types with basic parameter display
+type genericRenderer struct {
+ baseRenderer
+}
-func (genericRenderer) Render(v *toolCallCmp) string {
- header := makeHeader(prettifyToolName(v.call.Name), v.textWidth(), v.call.Input)
- if res, done := earlyState(header, v); done {
- return res
- }
- body := renderPlainContent(v, v.result.Content)
- return joinHeaderBody(header, body)
+// 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
// -----------------------------------------------------------------------------
-type bashRenderer struct{}
-
-func (bashRenderer) Render(v *toolCallCmp) string {
- var p tools.BashParams
- _ = json.Unmarshal([]byte(v.call.Input), &p)
+// bashRenderer handles bash command execution display
+type bashRenderer struct {
+ baseRenderer
+}
- cmd := strings.ReplaceAll(p.Command, "\n", " ")
- header := makeHeader("Bash", v.textWidth(), cmd)
- if res, done := earlyState(header, v); done {
- return res
+// 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")
}
- body := renderPlainContent(v, v.result.Content)
- return joinHeaderBody(header, body)
+
+ 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)
+ })
+}
+
+// renderError provides consistent error rendering
+func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
+ header := makeHeader("Error", v.textWidth(), message)
+ return joinHeaderBody(header, "")
}
// -----------------------------------------------------------------------------
// View renderer
// -----------------------------------------------------------------------------
-type viewRenderer struct{}
+// viewRenderer handles file viewing with syntax highlighting and line numbers
+type viewRenderer struct {
+ baseRenderer
+}
-func (viewRenderer) Render(v *toolCallCmp) string {
+// Render displays file content with optional limit and offset parameters
+func (vr viewRenderer) Render(v *toolCallCmp) string {
var params tools.ViewParams
- _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+ if err := vr.unmarshalParams(v.call.Input, ¶ms); err != nil {
+ return vr.renderError(v, "Invalid view parameters")
+ }
file := removeWorkingDirPrefix(params.FilePath)
- args := []string{file}
- if params.Limit != 0 {
- args = append(args, "limit", fmt.Sprintf("%d", params.Limit))
- }
- if params.Offset != 0 {
- args = append(args, "offset", fmt.Sprintf("%d", params.Offset))
- }
+ 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)
+ })
+}
- header := makeHeader("View", v.textWidth(), args...)
- if res, done := earlyState(header, v); done {
- return res
+// formatNonZero returns string representation of non-zero integers, empty string for zero
+func formatNonZero(value int) string {
+ if value == 0 {
+ return ""
}
-
- var meta tools.ViewResponseMetadata
- _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
-
- body := renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
- return joinHeaderBody(header, body)
+ return fmt.Sprintf("%d", value)
}
// -----------------------------------------------------------------------------
// Edit renderer
// -----------------------------------------------------------------------------
-type editRenderer struct{}
+// editRenderer handles file editing with diff visualization
+type editRenderer struct {
+ baseRenderer
+}
-func (editRenderer) Render(v *toolCallCmp) string {
+// Render displays the edited file with a formatted diff of changes
+func (er editRenderer) Render(v *toolCallCmp) string {
var params tools.EditParams
- _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+ if err := er.unmarshalParams(v.call.Input, ¶ms); err != nil {
+ return er.renderError(v, "Invalid edit parameters")
+ }
file := removeWorkingDirPrefix(params.FilePath)
- header := makeHeader("Edit", v.textWidth(), file)
- if res, done := earlyState(header, v); done {
- return res
- }
+ args := newParamBuilder().addMain(file).build()
- var meta tools.EditResponseMetadata
- _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+ 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()))
- return joinHeaderBody(header, diffView)
+ trunc := truncateHeight(meta.Diff, responseContextHeight)
+ diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
+ return diffView
+ })
}
// -----------------------------------------------------------------------------
// Write renderer
// -----------------------------------------------------------------------------
-type writeRenderer struct{}
+// writeRenderer handles file writing with syntax-highlighted content preview
+type writeRenderer struct {
+ baseRenderer
+}
-func (writeRenderer) Render(v *toolCallCmp) string {
+// Render displays the file being written with syntax highlighting
+func (wr writeRenderer) Render(v *toolCallCmp) string {
var params tools.WriteParams
- _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+ if err := wr.unmarshalParams(v.call.Input, ¶ms); err != nil {
+ return wr.renderError(v, "Invalid write parameters")
+ }
file := removeWorkingDirPrefix(params.FilePath)
- header := makeHeader("Write", v.textWidth(), file)
- if res, done := earlyState(header, v); done {
- return res
- }
+ args := newParamBuilder().addMain(file).build()
- body := renderCodeContent(v, file, params.Content, 0)
- return joinHeaderBody(header, body)
+ return wr.renderWithParams(v, "Write", args, func() string {
+ return renderCodeContent(v, file, params.Content, 0)
+ })
}
// -----------------------------------------------------------------------------
// Fetch renderer
// -----------------------------------------------------------------------------
-type fetchRenderer struct{}
+// fetchRenderer handles URL fetching with format-specific content display
+type fetchRenderer struct {
+ baseRenderer
+}
-func (fetchRenderer) Render(v *toolCallCmp) string {
+// Render displays the fetched URL with format and timeout parameters
+func (fr fetchRenderer) Render(v *toolCallCmp) string {
var params tools.FetchParams
- _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
-
- args := []string{params.URL}
- if params.Format != "" {
- args = append(args, "format", params.Format)
- }
- if params.Timeout != 0 {
- args = append(args, "timeout", (time.Duration(params.Timeout) * time.Second).String())
+ if err := fr.unmarshalParams(v.call.Input, ¶ms); err != nil {
+ return fr.renderError(v, "Invalid fetch parameters")
}
- header := makeHeader("Fetch", v.textWidth(), args...)
- if res, done := earlyState(header, v); done {
- return res
- }
+ args := newParamBuilder().
+ addMain(params.URL).
+ addKeyValue("format", params.Format).
+ addKeyValue("timeout", formatTimeout(params.Timeout)).
+ build()
- file := "fetch.md"
- switch params.Format {
+ 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":
- file = "fetch.txt"
+ return "fetch.txt"
case "html":
- file = "fetch.html"
+ return "fetch.html"
+ default:
+ return "fetch.md"
}
+}
- body := renderCodeContent(v, file, v.result.Content, 0)
- return joinHeaderBody(header, body)
+// 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
// -----------------------------------------------------------------------------
-type globRenderer struct{}
+// globRenderer handles file pattern matching with path filtering
+type globRenderer struct {
+ baseRenderer
+}
-func (globRenderer) Render(v *toolCallCmp) string {
+// Render displays the glob pattern with optional path parameter
+func (gr globRenderer) Render(v *toolCallCmp) string {
var params tools.GlobParams
- _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
-
- args := []string{params.Pattern}
- if params.Path != "" {
- args = append(args, "path", params.Path)
+ if err := gr.unmarshalParams(v.call.Input, ¶ms); err != nil {
+ return gr.renderError(v, "Invalid glob parameters")
}
- header := makeHeader("Glob", v.textWidth(), args...)
- if res, done := earlyState(header, v); done {
- return res
- }
+ args := newParamBuilder().
+ addMain(params.Pattern).
+ addKeyValue("path", params.Path).
+ build()
- body := renderPlainContent(v, v.result.Content)
- return joinHeaderBody(header, body)
+ return gr.renderWithParams(v, "Glob", args, func() string {
+ return renderPlainContent(v, v.result.Content)
+ })
}
// -----------------------------------------------------------------------------
// Grep renderer
// -----------------------------------------------------------------------------
-type grepRenderer struct{}
+// grepRenderer handles content searching with pattern matching options
+type grepRenderer struct {
+ baseRenderer
+}
-func (grepRenderer) Render(v *toolCallCmp) string {
+// Render displays the search pattern with path, include, and literal text options
+func (gr grepRenderer) Render(v *toolCallCmp) string {
var params tools.GrepParams
- _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
-
- args := []string{params.Pattern}
- if params.Path != "" {
- args = append(args, "path", params.Path)
- }
- if params.Include != "" {
- args = append(args, "include", params.Include)
- }
- if params.LiteralText {
- args = append(args, "literal", "true")
+ if err := gr.unmarshalParams(v.call.Input, ¶ms); err != nil {
+ return gr.renderError(v, "Invalid grep parameters")
}
- header := makeHeader("Grep", v.textWidth(), args...)
- if res, done := earlyState(header, v); done {
- return res
- }
+ args := newParamBuilder().
+ addMain(params.Pattern).
+ addKeyValue("path", params.Path).
+ addKeyValue("include", params.Include).
+ addFlag("literal", params.LiteralText).
+ build()
- body := renderPlainContent(v, v.result.Content)
- return joinHeaderBody(header, body)
+ return gr.renderWithParams(v, "Grep", args, func() string {
+ return renderPlainContent(v, v.result.Content)
+ })
}
// -----------------------------------------------------------------------------
// LS renderer
// -----------------------------------------------------------------------------
-type lsRenderer struct{}
+// lsRenderer handles directory listing with default path handling
+type lsRenderer struct {
+ baseRenderer
+}
-func (lsRenderer) Render(v *toolCallCmp) string {
+// Render displays the directory path, defaulting to current directory
+func (lr lsRenderer) Render(v *toolCallCmp) string {
var params tools.LSParams
- _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
+ if err := lr.unmarshalParams(v.call.Input, ¶ms); err != nil {
+ return lr.renderError(v, "Invalid ls parameters")
+ }
path := params.Path
if path == "" {
path = "."
}
- header := makeHeader("List", v.textWidth(), path)
- if res, done := earlyState(header, v); done {
- return res
- }
+ args := newParamBuilder().addMain(path).build()
- body := renderPlainContent(v, v.result.Content)
- return joinHeaderBody(header, body)
+ return lr.renderWithParams(v, "List", args, func() string {
+ return renderPlainContent(v, v.result.Content)
+ })
}
// -----------------------------------------------------------------------------
// Sourcegraph renderer
// -----------------------------------------------------------------------------
-type sourcegraphRenderer struct{}
+// sourcegraphRenderer handles code search with count and context options
+type sourcegraphRenderer struct {
+ baseRenderer
+}
-func (sourcegraphRenderer) Render(v *toolCallCmp) string {
+// Render displays the search query with optional count and context window parameters
+func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
var params tools.SourcegraphParams
- _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
-
- args := []string{params.Query}
- if params.Count != 0 {
- args = append(args, "count", fmt.Sprintf("%d", params.Count))
- }
- if params.ContextWindow != 0 {
- args = append(args, "context", fmt.Sprintf("%d", params.ContextWindow))
+ if err := sr.unmarshalParams(v.call.Input, ¶ms); err != nil {
+ return sr.renderError(v, "Invalid sourcegraph parameters")
}
- header := makeHeader("Sourcegraph", v.textWidth(), args...)
- if res, done := earlyState(header, v); done {
- return res
- }
+ args := newParamBuilder().
+ addMain(params.Query).
+ addKeyValue("count", formatNonZero(params.Count)).
+ addKeyValue("context", formatNonZero(params.ContextWindow)).
+ build()
- body := renderPlainContent(v, v.result.Content)
- return joinHeaderBody(header, body)
+ return sr.renderWithParams(v, "Sourcegraph", args, func() string {
+ return renderPlainContent(v, v.result.Content)
+ })
}
// -----------------------------------------------------------------------------
// Patch renderer
// -----------------------------------------------------------------------------
-type patchRenderer struct{}
+// patchRenderer handles multi-file patches with change summaries
+type patchRenderer struct {
+ baseRenderer
+}
-func (patchRenderer) Render(v *toolCallCmp) string {
+// Render displays patch summary with file count and change statistics
+func (pr patchRenderer) Render(v *toolCallCmp) string {
var params tools.PatchParams
- _ = json.Unmarshal([]byte(v.call.Input), ¶ms)
-
- header := makeHeader("Patch", v.textWidth(), "multiple files")
- if res, done := earlyState(header, v); done {
- return res
+ if err := pr.unmarshalParams(v.call.Input, ¶ms); err != nil {
+ return pr.renderError(v, "Invalid patch parameters")
}
- var meta tools.PatchResponseMetadata
- _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+ args := newParamBuilder().addMain("multiple files").build()
- // Format the result as a summary of changes
- summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
- len(meta.FilesChanged), meta.Additions, meta.Removals)
+ 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)
+ }
- // List the changed files
- filesList := strings.Join(meta.FilesChanged, "\n")
+ summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
+ len(meta.FilesChanged), meta.Additions, meta.Removals)
+ filesList := strings.Join(meta.FilesChanged, "\n")
- body := renderPlainContent(v, summary+"\n\n"+filesList)
- return joinHeaderBody(header, body)
+ return renderPlainContent(v, summary+"\n\n"+filesList)
+ })
}
// -----------------------------------------------------------------------------
// Diagnostics renderer
// -----------------------------------------------------------------------------
-type diagnosticsRenderer struct{}
+// diagnosticsRenderer handles project-wide diagnostic information
+type diagnosticsRenderer struct {
+ baseRenderer
+}
-func (diagnosticsRenderer) Render(v *toolCallCmp) string {
- header := makeHeader("Diagnostics", v.textWidth(), "project")
- if res, done := earlyState(header, v); done {
- return res
- }
+// Render displays project diagnostics with plain content formatting
+func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
+ args := newParamBuilder().addMain("project").build()
- body := renderPlainContent(v, v.result.Content)
- return joinHeaderBody(header, body)
+ return dr.renderWithParams(v, "Diagnostics", args, func() string {
+ return renderPlainContent(v, v.result.Content)
+ })
}
// makeHeader builds ": param (key=value)" and truncates as needed.
func makeHeader(tool string, width int, params ...string) string {
prefix := tool + ": "
- return prefix + renderParams(width-lipgloss.Width(prefix), params...)
+ return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
}
-// renders params, params[0] (params[1]=params[2] ....)
-func renderParams(paramsWidth int, params ...string) string {
+// renderParamList renders params, params[0] (params[1]=params[2] ....)
+func renderParamList(paramsWidth int, params ...string) string {
if len(params) == 0 {
return ""
}
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index d51f659262b83ff6a7ec6cc70c3c4eda189fe987..bee94d67795fb288fe6e062f68b57155ed4ccbb5 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -15,46 +15,57 @@ import (
"github.com/opencode-ai/opencode/internal/tui/util"
)
+// 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
- layout.Sizeable
- layout.Focusable
- GetToolCall() message.ToolCall
- GetToolResult() message.ToolResult
- SetToolResult(message.ToolResult)
- SetToolCall(message.ToolCall)
- SetCancelled()
- ParentMessageId() string
- Spinning() bool
-}
-
+ 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
+}
+
+// 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
- focused bool
+ width int // Component width for text wrapping
+ focused bool // Focus state for border styling
- parentMessageId string
- call message.ToolCall
- result message.ToolResult
- cancelled bool
+ // 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
- spinning bool
- anim util.Model
+ // Animation state for pending tool calls
+ spinning bool // Whether to show loading animation
+ anim util.Model // Animation component for pending states
}
+// 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
}
}
+// 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,
@@ -67,6 +78,8 @@ func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCal
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()
logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
@@ -76,6 +89,8 @@ func (m *toolCallCmp) Init() tea.Cmd {
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) {
logging.Debug("Tool call update", "msg", msg)
switch msg := msg.(type) {
@@ -89,6 +104,8 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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() string {
box := m.style()
@@ -100,12 +117,14 @@ func (m *toolCallCmp) View() string {
return box.PaddingLeft(1).Render(r.Render(m))
}
-// SetCancelled implements ToolCallCmp.
+// State management methods
+
+// SetCancelled marks the tool call as cancelled
func (m *toolCallCmp) SetCancelled() {
m.cancelled = true
}
-// SetToolCall implements ToolCallCmp.
+// 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 {
@@ -113,31 +132,36 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
}
}
-// ParentMessageId implements ToolCallCmp.
+// ParentMessageId returns the ID of the message that initiated this tool call
func (m *toolCallCmp) ParentMessageId() string {
return m.parentMessageId
}
-// SetToolResult implements ToolCallCmp.
+// SetToolResult updates the tool result and stops the spinning animation
func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
m.result = result
m.spinning = false
}
-// GetToolCall implements ToolCallCmp.
+// GetToolCall returns the current tool call data
func (m *toolCallCmp) GetToolCall() message.ToolCall {
return m.call
}
-// GetToolResult implements ToolCallCmp.
+// GetToolResult returns the current tool result data
func (m *toolCallCmp) GetToolResult() message.ToolResult {
return m.result
}
+// Rendering methods
+
+// renderPending displays the tool name with a loading animation for pending tool calls
func (m *toolCallCmp) renderPending() string {
return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), 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 := theme.CurrentTheme()
borderStyle := lipgloss.NormalBorder()
@@ -151,10 +175,13 @@ func (m *toolCallCmp) style() lipgloss.Style {
BorderStyle(borderStyle)
}
+// textWidth calculates the available width for text content,
+// accounting for borders and padding
func (m *toolCallCmp) textWidth() int {
return m.width - 2 // 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 := theme.CurrentTheme()
lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
@@ -162,30 +189,40 @@ func (m *toolCallCmp) fit(content string, width int) string {
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 implements MessageModel.
+// 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
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 {
if !m.call.Finished {
return true
@@ -195,6 +232,7 @@ func (m *toolCallCmp) shouldSpin() bool {
return false
}
+// Spinning returns whether the tool call is currently showing a loading animation
func (m *toolCallCmp) Spinning() bool {
return m.spinning
}
diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go
index 1c26ef26764bb09d1e7219ccc8f7cb4fe29b0b80..db1eeafc973f85218b36750c79d337bf9ed41e02 100644
--- a/internal/tui/components/core/list/keys.go
+++ b/internal/tui/components/core/list/keys.go
@@ -16,7 +16,7 @@ type KeyMap struct {
Submit key.Binding
}
-func defaultKeymap() KeyMap {
+func DefaultKeymap() KeyMap {
return KeyMap{
Down: key.NewBinding(
key.WithKeys("down", "ctrl+j", "ctrl+n"),
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 628b6835f83ec17d0a853889043290c7244a5006..4bcb167a5062942d83d938073e4356f2d834c02e 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -3,7 +3,6 @@ package list
import (
"slices"
"strings"
- "sync"
"github.com/charmbracelet/bubbles/v2/help"
"github.com/charmbracelet/bubbles/v2/key"
@@ -14,89 +13,155 @@ import (
"github.com/opencode-ai/opencode/internal/tui/util"
)
+// 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
- SetItems([]util.Model) tea.Cmd
- AppendItem(util.Model) tea.Cmd
- PrependItem(util.Model) tea.Cmd
- DeleteItem(int)
- UpdateItem(int, util.Model)
- ResetView()
- Items() []util.Model
+ 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
}
+// HasAnim interface identifies items that support animation.
+// Items implementing this interface will receive animation update messages.
type HasAnim interface {
util.Model
- Spinning() bool
+ Spinning() bool // Returns true if the item is currently animating
}
+// renderedItem represents a cached rendered item with its position and content.
type renderedItem struct {
- lines []string
- start int
- height int
+ 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 {
- width, height, offset int
- finalHeight int // this gets set when the last item is rendered to mark the max offset
- reverse bool
- help help.Model
- keymap KeyMap
- items []util.Model
- renderedItems *sync.Map // item index to rendered string
- needsRerender bool
- renderedLines []string
- selectedItemInx int
- lastRenderedInx int
- content string
- gapSize int
- padding []int
+ 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
+ items []util.Model // The actual list items
+ gapSize int // Number of empty lines between items
+ padding []int // Padding around the list content
}
+// 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.items = items
}
}
+// 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 {
m := &model{
- help: help.New(),
- keymap: defaultKeymap(),
- items: []util.Model{},
- needsRerender: true,
- gapSize: 0,
- padding: []int{},
- selectedItemInx: -1,
- finalHeight: -1,
- lastRenderedInx: -1,
- renderedItems: new(sync.Map),
+ help: help.New(),
+ keymap: DefaultKeymap(),
+ items: []util.Model{},
+ renderState: newRenderState(),
+ gapSize: DefaultGapSize,
+ padding: []int{},
+ selectionState: selectionState{selectedIndex: NoSelection},
}
for _, opt := range opts {
opt(m)
@@ -104,591 +169,737 @@ func New(opts ...listOptions) ListModel {
return m
}
-// Init implements List.
+// 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 {
- cmds := []tea.Cmd{
- m.SetItems(m.items),
- }
- return tea.Batch(cmds...)
+ return m.SetItems(m.items)
}
-// Update implements List.
+// 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) {
- var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
- if m.reverse {
- m.decreaseOffset(1)
- } else {
- m.increaseOffset(1)
- }
- return m, nil
- case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
- if m.reverse {
- m.increaseOffset(1)
- } else {
- m.decreaseOffset(1)
- }
- return m, nil
- case key.Matches(msg, m.keymap.DownOneItem):
- m.downOneItem()
- return m, nil
- case key.Matches(msg, m.keymap.UpOneItem):
- m.upOneItem()
- return m, nil
- case key.Matches(msg, m.keymap.HalfPageDown):
- if m.reverse {
- m.decreaseOffset(m.listHeight() / 2)
- } else {
- m.increaseOffset(m.listHeight() / 2)
- }
- return m, nil
- case key.Matches(msg, m.keymap.HalfPageUp):
- if m.reverse {
- m.increaseOffset(m.listHeight() / 2)
- } else {
- m.decreaseOffset(m.listHeight() / 2)
- }
- return m, nil
- case key.Matches(msg, m.keymap.Home):
- m.goToTop()
- return m, nil
- case key.Matches(msg, m.keymap.End):
- m.goToBottom()
- return m, nil
- }
+ return m.handleKeyPress(msg)
case anim.ColorCycleMsg, anim.StepCharsMsg:
- for inx, item := range m.items {
- if i, ok := item.(HasAnim); ok {
- if i.Spinning() {
- updated, cmd := i.Update(msg)
- cmds = append(cmds, cmd)
- m.UpdateItem(inx, updated.(util.Model))
- }
+ return m.handleAnimationMsg(msg)
+ }
+
+ if m.selectionState.isValidIndex(len(m.items)) {
+ return m.updateSelectedItem(msg)
+ }
+
+ return m, nil
+}
+
+// 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()
+ }
+ 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.items {
+ 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...)
}
- if m.selectedItemInx > -1 {
- u, cmd := m.items[m.selectedItemInx].Update(msg)
- cmds = append(cmds, cmd)
- m.UpdateItem(m.selectedItemInx, u.(util.Model))
- return m, tea.Batch(cmds...)
+ 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.items[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)
}
+}
- return m, nil
+// 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)
+ }
}
-// View implements List.
+// 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() string {
- if m.height == 0 || m.width == 0 {
+ if m.viewState.height == 0 || m.viewState.width == 0 {
return ""
}
- if m.needsRerender {
+ if m.renderState.needsRerender {
m.renderVisible()
}
- return lipgloss.NewStyle().Padding(m.padding...).Height(m.height).Render(m.content)
+ return lipgloss.NewStyle().Padding(m.padding...).Height(m.viewState.height).Render(m.viewState.content)
}
-// Items implements ListModel.
+// Items returns a copy of all items in the list.
func (m *model) Items() []util.Model {
return m.items
}
-func (m *model) renderVisibleReverse() {
- start := 0
- cutoff := m.offset + m.listHeight()
- items := m.items
- if m.lastRenderedInx > -1 {
- items = m.items[:m.lastRenderedInx]
- start = len(m.renderedLines)
+// 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 {
- // reveresed so that it starts at the end
- m.lastRenderedInx = len(m.items)
- }
- realIndex := m.lastRenderedInx
- for i := len(items) - 1; i >= 0; i-- {
- realIndex--
- var itemLines []string
- cachedContent, ok := m.renderedItems.Load(realIndex)
- if ok {
- itemLines = cachedContent.(renderedItem).lines
- } else {
- itemLines = strings.Split(items[i].View(), "\n")
- if m.gapSize > 0 {
- for range m.gapSize {
- itemLines = append(itemLines, "")
- }
- }
- m.renderedItems.Store(realIndex, renderedItem{
- lines: itemLines,
- start: start,
- height: len(itemLines),
- })
- }
+ m.renderVisibleForward()
+ }
+}
- if realIndex == 0 {
- m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
- }
- m.renderedLines = append(itemLines, m.renderedLines...)
- m.lastRenderedInx = realIndex
- // always render the next item
- if start > cutoff {
- break
- }
- start += len(itemLines)
+// 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(),
+ items: m.items,
+ realIdx: m.renderState.lastIndex,
}
- m.needsRerender = false
- if m.finalHeight > -1 {
- // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
- m.offset = min(m.offset, m.finalHeight)
+
+ if m.renderState.lastIndex > NotRendered {
+ renderer.items = m.items[m.renderState.lastIndex+1:]
+ renderer.start = len(m.renderState.lines)
}
- maxHeight := min(m.listHeight(), len(m.renderedLines))
- if m.offset < len(m.renderedLines) {
- end := len(m.renderedLines) - m.offset
- start := max(0, end-maxHeight)
- m.content = strings.Join(m.renderedLines[start:end], "\n")
+
+ 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(),
+ items: m.items,
+ realIdx: m.renderState.lastIndex,
+ }
+
+ if m.renderState.lastIndex > NotRendered {
+ renderer.items = m.items[:m.renderState.lastIndex]
+ renderer.start = len(m.renderState.lines)
} else {
- m.content = ""
+ m.renderState.lastIndex = len(m.items)
+ renderer.realIdx = len(m.items)
}
+
+ renderer.render()
+ m.finalizeRender()
}
-func (m *model) renderVisible() {
- if m.reverse {
- m.renderVisibleReverse()
+// 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
}
- start := 0
- cutoff := m.offset + m.listHeight()
- items := m.items
- if m.lastRenderedInx > -1 {
- items = m.items[m.lastRenderedInx+1:]
- start = len(m.renderedLines)
+
+ 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")
}
+}
- realIndex := m.lastRenderedInx
- for _, item := range items {
- realIndex++
+// 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
+}
- var itemLines []string
- cachedContent, ok := m.renderedItems.Load(realIndex)
- if ok {
- itemLines = cachedContent.(renderedItem).lines
- } else {
- itemLines = strings.Split(item.View(), "\n")
- if m.gapSize > 0 {
- for range m.gapSize {
- itemLines = append(itemLines, "")
- }
- }
- m.renderedItems.Store(realIndex, renderedItem{
- lines: itemLines,
- start: start,
- height: len(itemLines),
- })
+// 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.items)-1 {
+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
}
- // always render the next item
- if start > cutoff {
+
+ 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
}
- if realIndex == len(m.items)-1 {
- m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
+ itemLines := r.getOrRenderItem(r.items[i])
+ if r.realIdx == 0 {
+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
}
- m.renderedLines = append(m.renderedLines, itemLines...)
- m.lastRenderedInx = realIndex
- start += len(itemLines)
+ 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.
+func (m *model) selectPreviousItem() tea.Cmd {
+ if m.selectionState.selectedIndex <= 0 {
+ return nil
+ }
+
+ cmds := []tea.Cmd{m.blurSelected()}
+ m.selectionState.selectedIndex--
+ 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.
+func (m *model) selectNextItem() tea.Cmd {
+ if m.selectionState.selectedIndex >= len(m.items)-1 || m.selectionState.selectedIndex < 0 {
+ return nil
}
- m.needsRerender = false
- maxHeight := min(m.listHeight(), len(m.renderedLines))
- if m.finalHeight > -1 {
- // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
- m.offset = min(m.offset, m.finalHeight)
+
+ cmds := []tea.Cmd{m.blurSelected()}
+ m.selectionState.selectedIndex++
+ cmds = append(cmds, m.focusSelected())
+ m.ensureSelectedItemVisible()
+ return tea.Batch(cmds...)
+}
+
+// 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.offset < len(m.renderedLines) {
- m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
+
+ if m.viewState.reverse {
+ m.ensureVisibleReverse(cachedItem)
} else {
- m.content = ""
+ m.ensureVisibleForward(cachedItem)
}
+ m.renderState.needsRerender = true
}
-func (m *model) upOneItem() tea.Cmd {
- var cmds []tea.Cmd
- if m.selectedItemInx > 0 {
- cmd := m.blurSelected()
- cmds = append(cmds, cmd)
- m.selectedItemInx--
- cmd = m.focusSelected()
- cmds = append(cmds, cmd)
- }
-
- cached, ok := m.renderedItems.Load(m.selectedItemInx)
- if ok {
- // already rendered
- if !m.reverse {
- cachedItem, _ := cached.(renderedItem)
- // might not fit on the screen move the offset to the start of the item
- if cachedItem.height >= m.listHeight() {
- changeNeeded := m.offset - cachedItem.start
- m.decreaseOffset(changeNeeded)
- }
- if cachedItem.start < m.offset {
- changeNeeded := m.offset - cachedItem.start
- m.decreaseOffset(changeNeeded)
- }
+// 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 {
- cachedItem, _ := cached.(renderedItem)
- // might not fit on the screen move the offset to the start of the item
- if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
- changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
- m.increaseOffset(changeNeeded)
- }
+ 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)
}
}
- m.needsRerender = true
- return tea.Batch(cmds...)
}
-func (m *model) downOneItem() tea.Cmd {
- var cmds []tea.Cmd
- if m.selectedItemInx < len(m.items)-1 {
- cmd := m.blurSelected()
- cmds = append(cmds, cmd)
- m.selectedItemInx++
- cmd = m.focusSelected()
- cmds = append(cmds, cmd)
- }
- cached, ok := m.renderedItems.Load(m.selectedItemInx)
- if ok {
- // already rendered
- if !m.reverse {
- cachedItem, _ := cached.(renderedItem)
- // might not fit on the screen move the offset to the start of the item
- if cachedItem.height >= m.listHeight() {
- changeNeeded := cachedItem.start - m.offset
- m.increaseOffset(changeNeeded)
- } else {
- end := cachedItem.start + cachedItem.height
- if end > m.offset+m.listHeight() {
- changeNeeded := end - (m.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.items)-1 {
+ changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
+ m.decreaseOffset(changeNeeded)
} else {
- cachedItem, _ := cached.(renderedItem)
- // might not fit on the screen move the offset to the start of the item
- if cachedItem.height >= m.listHeight() {
- changeNeeded := m.offset - (cachedItem.start + cachedItem.height - m.listHeight())
- m.decreaseOffset(changeNeeded)
- } else {
- if cachedItem.start < m.offset {
- changeNeeded := m.offset - cachedItem.start
- m.decreaseOffset(changeNeeded)
- }
- }
+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
+ m.increaseOffset(changeNeeded)
}
+ return
}
- m.needsRerender = true
- return tea.Batch(cmds...)
+ 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 item.
+// Commonly used for chat-like interfaces where new content appears at the bottom.
func (m *model) goToBottom() tea.Cmd {
- if len(m.items) == 0 {
- return nil
- }
- var cmds []tea.Cmd
- m.reverse = true
- cmd := m.blurSelected()
- cmds = append(cmds, cmd)
- m.selectedItemInx = len(m.items) - 1
- cmd = m.focusSelected()
- cmds = append(cmds, cmd)
+ cmds := []tea.Cmd{m.blurSelected()}
+ m.viewState.reverse = true
+ m.selectionState.selectedIndex = len(m.items) - 1
+ cmds = append(cmds, m.focusSelected())
m.ResetView()
return tea.Batch(cmds...)
}
-func (m *model) ResetView() {
- m.renderedItems.Clear()
- m.renderedLines = []string{}
- m.offset = 0
- m.lastRenderedInx = -1
- m.finalHeight = -1
- m.needsRerender = true
-}
-
+// goToTop switches to forward mode and selects the first item.
+// Standard behavior for most list interfaces.
func (m *model) goToTop() tea.Cmd {
- if len(m.items) == 0 {
- return nil
+ cmds := []tea.Cmd{m.blurSelected()}
+ m.viewState.reverse = false
+ if len(m.items) > 0 {
+ m.selectionState.selectedIndex = 0
}
- var cmds []tea.Cmd
- m.reverse = false
- cmd := m.blurSelected()
- cmds = append(cmds, cmd)
- m.selectedItemInx = 0
- cmd = m.focusSelected()
- cmds = append(cmds, cmd)
+ 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.selectedItemInx == -1 {
+ if !m.selectionState.isValidIndex(len(m.items)) {
return nil
}
- if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
cmd := i.Focus()
- m.rerenderItem(m.selectedItemInx)
+ 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.selectedItemInx == -1 {
+ if !m.selectionState.isValidIndex(len(m.items)) {
return nil
}
- if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
cmd := i.Blur()
- m.rerenderItem(m.selectedItemInx)
+ 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 || len(m.renderedLines) == 0 {
+ if inx < 0 || inx >= len(m.items) || len(m.renderState.lines) == 0 {
return
}
- cached, ok := m.renderedItems.Load(inx)
- cachedItem, _ := cached.(renderedItem)
+
+ cachedItem, ok := m.renderState.items[inx]
if !ok {
- // No need to rerender
return
}
- rerenderedItem := m.items[inx].View()
- rerenderedLines := strings.Split(rerenderedItem, "\n")
- if m.gapSize > 0 {
- for range m.gapSize {
- rerenderedLines = append(rerenderedLines, "")
- }
- }
- // check if lines are the same
+
+ rerenderedLines := m.getItemLines(m.items[inx])
if slices.Equal(cachedItem.lines, rerenderedLines) {
- // No changes
return
}
- // check if the item is in the content
- start := cachedItem.start
- end := start + cachedItem.height
- totalLines := len(m.renderedLines)
- if m.reverse {
+
+ 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.
+func (m *model) getItemLines(item util.Model) []string {
+ itemLines := strings.Split(item.View(), "\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
}
- if start <= totalLines && end <= totalLines {
- m.renderedLines = slices.Delete(m.renderedLines, start, end)
- m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
+ 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.items)-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)
}
- // TODO: if hight changed do something
- if cachedItem.height != len(rerenderedLines) {
- if inx == len(m.items)-1 {
- m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
+}
+
+// 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.items); i++ {
+ if existing, ok := m.renderState.items[i]; ok {
+ existing.start = currentStart
+ currentStart += existing.height
+ m.renderState.items[i] = existing
+ } else {
+ break
}
+ }
+}
- // update the start of the other cached items
- currentStart := cachedItem.start + len(rerenderedLines)
- if m.reverse {
- for i := inx - 1; i < len(m.items); i-- {
- if existing, ok := m.renderedItems.Load(i); ok {
- cached := existing.(renderedItem)
- cached.start = currentStart
- currentStart += cached.height
- m.renderedItems.Store(i, cached)
- } 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 {
- for i := inx + 1; i < len(m.items); i++ {
- if existing, ok := m.renderedItems.Load(i); ok {
- cached := existing.(renderedItem)
- cached.start = currentStart
- currentStart += cached.height
- m.renderedItems.Store(i, cached)
- } else {
- break
- }
- }
+ break
}
}
- m.renderedItems.Store(inx, renderedItem{
- lines: rerenderedLines,
+}
+
+// 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(rerenderedLines),
- })
- m.needsRerender = true
+ 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.finalHeight > -1 {
- if m.offset < m.finalHeight {
- m.offset += n
- if m.offset > m.finalHeight {
- m.offset = m.finalHeight
+ 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.needsRerender = true
+ m.renderState.needsRerender = true
}
} else {
- m.offset += n
- m.needsRerender = true
+ 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.offset > 0 {
- m.offset -= n
- if m.offset < 0 {
- m.offset = 0
+ if m.viewState.offset > 0 {
+ m.viewState.offset -= n
+ if m.viewState.offset < 0 {
+ m.viewState.offset = 0
}
- m.needsRerender = true
+ m.renderState.needsRerender = true
}
}
-// UpdateItem implements List.
+// 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.items) {
+ return
+ }
m.items[inx] = item
- if m.selectedItemInx == inx {
- if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+ if m.selectionState.selectedIndex == inx {
+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
i.Focus()
}
}
m.setItemSize(inx)
m.rerenderItem(inx)
- m.needsRerender = true
+ m.renderState.needsRerender = true
}
-// GetSize implements List.
+// GetSize returns the current dimensions of the list.
func (m *model) GetSize() (int, int) {
- return m.width, m.height
+ return m.viewState.width, m.viewState.height
}
-// SetSize implements List.
+// 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.width == width && m.height == height {
+ if m.viewState.width == width && m.viewState.height == height {
return nil
}
- if m.height != height {
- m.finalHeight = -1
- m.height = height
+ if m.viewState.height != height {
+ m.renderState.finalHeight = NoFinalHeight
+ m.viewState.height = height
}
- m.width = width
+ m.viewState.width = width
m.ResetView()
return m.setAllItemsSize()
}
+// getItemSize calculates the available width for items, accounting for padding.
func (m *model) getItemSize() int {
- width := m.width
- if m.padding != nil {
- if len(m.padding) == 1 {
- width -= m.padding[0] * 2
- } else if len(m.padding) == 2 || len(m.padding) == 3 {
- width -= m.padding[1] * 2
- } else if len(m.padding) == 4 {
- width -= m.padding[1] + m.padding[3]
- }
+ 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 width
+ 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.items) {
+ return nil
+ }
if i, ok := m.items[inx].(layout.Sizeable); ok {
- cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
- return cmd
+ return i.SetSize(m.getItemSize(), 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.items {
- cmd := m.setItemSize(i)
- cmds = append(cmds, cmd)
+ 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.height
- if m.padding != nil {
- if len(m.padding) == 1 {
- height -= m.padding[0] * 2
- } else if len(m.padding) == 2 {
- height -= m.padding[1] * 2
- } else if len(m.padding) == 3 {
- height -= m.padding[0] + m.padding[2]
- } else if len(m.padding) == 4 {
- height -= m.padding[0] + m.padding[2]
- }
+ 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]
}
- return height
+ return max(0, height)
}
-// AppendItem implements List.
+// 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 {
- var cmds []tea.Cmd
- cmd := item.Init()
- cmds = append(cmds, cmd)
+ cmds := []tea.Cmd{
+ item.Init(),
+ }
m.items = append(m.items, item)
- cmd = m.setItemSize(len(m.items) - 1)
- cmds = append(cmds, cmd)
- cmd = m.goToBottom()
- cmds = append(cmds, cmd)
- m.needsRerender = true
+ cmds = append(cmds, m.setItemSize(len(m.items)-1))
+ cmds = append(cmds, m.goToBottom())
+ m.renderState.needsRerender = true
return tea.Batch(cmds...)
}
-// DeleteItem implements List.
+// 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.items) {
+ return
+ }
m.items = slices.Delete(m.items, i, i+1)
- m.renderedItems.Delete(i)
- if m.selectedItemInx == i {
- m.selectedItemInx--
+ delete(m.renderState.items, i)
+
+ if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
+ m.selectionState.selectedIndex--
+ } else if m.selectionState.selectedIndex > i {
+ m.selectionState.selectedIndex--
}
+
m.ResetView()
- m.needsRerender = true
+ m.renderState.needsRerender = true
}
-// PrependItem implements List.
+// 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 {
- var cmds []tea.Cmd
- cmd := item.Init()
- cmds = append(cmds, cmd)
+ cmds := []tea.Cmd{item.Init()}
m.items = append([]util.Model{item}, m.items...)
- // update the indices of the rendered items
- newRenderedItems := make(map[int]renderedItem)
- m.renderedItems.Range(func(key any, value any) bool {
- keyInt := key.(int)
- renderedItem := value.(renderedItem)
- newKey := keyInt + 1
- newRenderedItems[newKey] = renderedItem
- return false
- })
- m.renderedItems.Clear()
- for k, v := range newRenderedItems {
- m.renderedItems.Store(k, v)
- }
- cmd = m.goToTop()
- cmds = append(cmds, cmd)
- cmd = m.setItemSize(0)
- cmds = append(cmds, cmd)
- m.needsRerender = true
+
+ // 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()
@@ -697,24 +908,29 @@ func (m *model) setReverse(reverse bool) {
}
}
-// SetItems implements List.
+// SetItems replaces all items in the list with a new set.
+// Initializes all items, sets their sizes, and establishes initial selection.
func (m *model) SetItems(items []util.Model) tea.Cmd {
m.items = items
- var cmds []tea.Cmd
- cmd := m.setAllItemsSize()
- cmds = append(cmds, cmd)
+ cmds := []tea.Cmd{m.setAllItemsSize()}
+
for _, item := range m.items {
cmds = append(cmds, item.Init())
}
- if m.reverse {
- m.selectedItemInx = len(m.items) - 1
- cmd := m.focusSelected()
- cmds = append(cmds, cmd)
+
+ if len(m.items) > 0 {
+ if m.viewState.reverse {
+ m.selectionState.selectedIndex = len(m.items) - 1
+ } else {
+ m.selectionState.selectedIndex = 0
+ }
+ if cmd := m.focusSelected(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
} else {
- m.selectedItemInx = 0
- cmd := m.focusSelected()
- cmds = append(cmds, cmd)
+ m.selectionState.selectedIndex = NoSelection
}
+
m.ResetView()
return tea.Batch(cmds...)
}
From 3eff8fa1d7a8c863bd571905ecdaa14aee7158c7 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Fri, 23 May 2025 18:40:26 +0200
Subject: [PATCH 18/73] fix lint
---
internal/tui/components/chat/list.go | 64 ++++++++++++++--------------
1 file changed, 32 insertions(+), 32 deletions(-)
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index b9b43590361a43627f33880bc97d4e7c65badfa1..57205c4c3480b889a994078d3fd8b0d7c79eaeb2 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -119,11 +119,11 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
m.handleChildSession(event)
return nil
}
-
+
if m.messageExists(event.Payload.ID) {
return nil
}
-
+
return m.handleNewMessage(event.Payload)
case pubsub.UpdatedEvent:
return m.handleUpdateAssistantMessage(event.Payload)
@@ -192,22 +192,22 @@ func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string)
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)
-
+
logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantIndex, "toolCalls", existingToolCalls)
-
+
// 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...)
}
@@ -215,7 +215,7 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C
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]
@@ -229,7 +229,7 @@ func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, me
}
}
}
-
+
return assistantIndex, toolCalls
}
@@ -238,10 +238,10 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
if assistantIndex == NotFound {
return nil
}
-
+
shouldShowMessage := m.shouldShowAssistantMessage(msg)
hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
-
+
if shouldShowMessage {
m.listCmp.UpdateItem(
assistantIndex,
@@ -253,7 +253,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
} else if hasToolCallsOnly {
m.listCmp.DeleteItem(assistantIndex)
}
-
+
return nil
}
@@ -265,13 +265,13 @@ func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
// 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...)
}
@@ -285,7 +285,7 @@ func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCa
return nil
}
}
-
+
// Add new tool call if not found
return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
}
@@ -293,7 +293,7 @@ func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCa
// 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(
@@ -304,13 +304,13 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
)
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...)
}
@@ -319,26 +319,26 @@ 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)
}
@@ -356,7 +356,7 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri
// 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:
@@ -366,14 +366,14 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
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(
@@ -384,30 +384,30 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
),
)
}
-
+
// 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...))
}
-
+
return uiMessages
}
// 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 options
}
From 9a69a310cf0df2af9eea6ed0e6da78c46d2182ae Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 24 May 2025 16:45:46 +0200
Subject: [PATCH 19/73] handle agent tool
---
internal/tui/components/anim/anim.go | 23 ++++-
internal/tui/components/chat/list.go | 76 +++++++++++++---
.../tui/components/chat/messages/messages.go | 3 +-
.../tui/components/chat/messages/renderer.go | 55 +++++++++++-
internal/tui/components/chat/messages/tool.go | 89 +++++++++++++++++--
internal/tui/components/core/list/list.go | 3 +-
6 files changed, 227 insertions(+), 22 deletions(-)
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index 91ae8317eafaa6c49fce54194b8f1013d88042f4..8c8276de23600147bbae7ab3cef1483cd05b2904 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -75,6 +75,11 @@ func cycleColors(id string) tea.Cmd {
})
}
+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 {
@@ -88,7 +93,15 @@ type anim struct {
id string
}
-func New(cyclingCharsSize uint, label string) util.Model {
+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)
@@ -105,6 +118,10 @@ func New(cyclingCharsSize uint, label string) util.Model {
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
@@ -204,6 +221,10 @@ func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
+func (a anim) ID() string {
+ return a.id
+}
+
func (a *anim) updateChars(chars *[]cyclingChar) {
for i, c := range *chars {
switch c.state(a.start) {
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index 57205c4c3480b889a994078d3fd8b0d7c79eaeb2..762fdd247af9bac5c55e562e56bd11eb307357db 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -8,7 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/logging"
+ "github.com/opencode-ai/opencode/internal/llm/agent"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/session"
@@ -106,9 +106,54 @@ func (m *messageListCmp) View() string {
}
// handleChildSession handles messages from child sessions (agent tools).
-// TODO: update the agent tool message with the changes
-func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
- // Implementation pending
+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...)
}
// handleMessageEvent processes different types of message events (created/updated).
@@ -116,16 +161,16 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
switch event.Type {
case pubsub.CreatedEvent:
if event.Payload.SessionID != m.session.ID {
- m.handleChildSession(event)
- return nil
+ 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
@@ -196,8 +241,6 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C
// Find existing assistant message and tool calls for this message
assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
- logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantIndex, "toolCalls", existingToolCalls)
-
// Handle assistant message content
if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
cmds = append(cmds, cmd)
@@ -389,6 +432,19 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
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
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index 714f79b3a46b2147bc51c115e0ac6b9ee4482f4d..b047af6bf36dc800e2755149d906f3ad2ee32a4c 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/llm/models"
@@ -80,7 +81,7 @@ func (m *messageCmp) Init() tea.Cmd {
// 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:
+ case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
m.spinning = m.shouldSpin()
if m.spinning {
u, cmd := m.anim.Update(msg)
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index b8a7b834543f33be69336eb3bef00493ed95d84c..836d9fb90cebc130f78dfe54a82da2c8be4e439f 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -91,7 +91,14 @@ func (pb *paramBuilder) build() []string {
// renderWithParams provides a common rendering pattern for tools with parameters
func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
- header := makeHeader(toolName, v.textWidth(), args...)
+ width := v.textWidth()
+ if v.isNested {
+ width -= 3 // Adjust for nested tool call indentation
+ }
+ header := makeHeader(toolName, width, args...)
+ if v.isNested {
+ return v.style().Render(header)
+ }
if res, done := earlyState(header, v); done {
return res
}
@@ -117,6 +124,7 @@ func init() {
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{} })
}
// -----------------------------------------------------------------------------
@@ -467,6 +475,51 @@ func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
})
}
+// -----------------------------------------------------------------------------
+// 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 := makeHeader("Task", v.textWidth(), args...)
+ parts := []string{header}
+ for _, call := range v.nestedToolCalls {
+ parts = append(parts, call.View())
+ }
+
+ if v.result.ToolCallID == "" {
+ v.spinning = true
+ parts = append(parts, v.anim.View())
+ } 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)
+}
+
// makeHeader builds ": param (key=value)" and truncates as needed.
func makeHeader(tool string, width int, params ...string) string {
prefix := tool + ": "
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index bee94d67795fb288fe6e062f68b57155ed4ccbb5..15c1d6d143c8c7d0622413872c0f72b9949d6210 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -3,10 +3,10 @@ package messages
import (
"fmt"
+ "github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
- "github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/tui/components/anim"
"github.com/opencode-ai/opencode/internal/tui/layout"
@@ -28,13 +28,17 @@ type ToolCallCmp interface {
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
+ 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
@@ -45,6 +49,8 @@ type toolCallCmp struct {
// 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
@@ -64,17 +70,32 @@ func WithToolCallResult(result message.ToolResult) ToolCallOption {
}
}
+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,
- anim: anim.New(15, "Working"),
}
for _, opt := range opts {
opt(m)
}
+ m.anim = anim.New(15, "Working")
+ if m.isNested {
+ m.anim = anim.New(10, "")
+ }
return m
}
@@ -82,7 +103,6 @@ func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCal
// Returns a command to start the animation for pending tool calls.
func (m *toolCallCmp) Init() tea.Cmd {
m.spinning = m.shouldSpin()
- logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
if m.spinning {
return m.anim.Init()
}
@@ -92,14 +112,22 @@ func (m *toolCallCmp) Init() tea.Cmd {
// 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) {
- logging.Debug("Tool call update", "msg", msg)
switch msg := msg.(type) {
- case anim.ColorCycleMsg, anim.StepCharsMsg:
+ 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)
- return m, cmd
+ cmds = append(cmds, cmd)
}
+ return m, tea.Batch(cmds...)
}
return m, nil
}
@@ -114,6 +142,15 @@ func (m *toolCallCmp) View() string {
}
r := registry.lookup(m.call.Name)
+
+ if m.isNested {
+ return box.Render(
+ lipgloss.JoinHorizontal(lipgloss.Left,
+ " └ ",
+ r.Render(m),
+ ),
+ )
+ }
return box.PaddingLeft(1).Render(r.Render(m))
}
@@ -153,10 +190,31 @@ 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 {
+ if m.isNested {
+ return fmt.Sprintf("└ %s: %s", prettifyToolName(m.call.Name), m.anim.View())
+ }
return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
}
@@ -164,6 +222,10 @@ func (m *toolCallCmp) renderPending() string {
// Applies muted colors and focus-dependent border styles.
func (m *toolCallCmp) style() lipgloss.Style {
t := theme.CurrentTheme()
+ if m.isNested {
+ return styles.BaseStyle().
+ Foreground(t.TextMuted())
+ }
borderStyle := lipgloss.NormalBorder()
if m.focused {
borderStyle = lipgloss.DoubleBorder()
@@ -218,6 +280,9 @@ func (m *toolCallCmp) GetSize() (int, int) {
// 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
}
@@ -234,5 +299,13 @@ func (m *toolCallCmp) shouldSpin() bool {
// 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/core/list/list.go b/internal/tui/components/core/list/list.go
index 4bcb167a5062942d83d938073e4356f2d834c02e..341b94b1ca5998f974f60f2d0a922ec1b16bd6f7 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/v2/help"
"github.com/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/components/anim"
@@ -182,7 +183,7 @@ 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:
+ case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
return m.handleAnimationMsg(msg)
}
From a23e1a37530978103a4eba7f7f5ce4a59cf0f768 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 24 May 2025 17:30:17 +0200
Subject: [PATCH 20/73] cleanup nested tool rendering
---
internal/tui/components/chat/list.go | 14 ++++++---
.../tui/components/chat/messages/renderer.go | 11 +++++--
internal/tui/components/chat/messages/tool.go | 13 +++------
internal/tui/components/core/list/keys.go | 29 ++++++++++---------
4 files changed, 38 insertions(+), 29 deletions(-)
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index 762fdd247af9bac5c55e562e56bd11eb307357db..e039e259f3223a5c3f6ef61cab4d7c77a228c062 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -46,13 +46,19 @@ type messageListCmp struct {
// and reverse ordering (newest messages at bottom).
func NewMessagesListCmp(app *app.App) MessageListCmp {
defaultKeymaps := list.DefaultKeymap()
- defaultKeymaps.NDown.SetEnabled(false)
- defaultKeymaps.NUp.SetEnabled(false)
+ defaultKeymaps.Up.SetEnabled(false)
+ defaultKeymaps.Down.SetEnabled(false)
+ defaultKeymaps.NDown = key.NewBinding(
+ key.WithKeys("ctrl+j"),
+ )
+ defaultKeymaps.NUp = key.NewBinding(
+ key.WithKeys("ctrl+k"),
+ )
defaultKeymaps.Home = key.NewBinding(
- key.WithKeys("ctrl+g"),
+ key.WithKeys("ctrl+shift+up"),
)
defaultKeymaps.End = key.NewBinding(
- key.WithKeys("ctrl+G"),
+ key.WithKeys("ctrl+shift+down"),
)
return &messageListCmp{
app: app,
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index 836d9fb90cebc130f78dfe54a82da2c8be4e439f..e559ff11f1c2345f61733ae21b4f1c1a2035f65f 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/lipgloss/v2/tree"
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/diff"
@@ -93,7 +94,7 @@ func (pb *paramBuilder) build() []string {
func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
width := v.textWidth()
if v.isNested {
- width -= 3 // Adjust for nested tool call indentation
+ width -= 4 // Adjust for nested tool call indentation
}
header := makeHeader(toolName, width, args...)
if v.isNested {
@@ -495,11 +496,15 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
args := newParamBuilder().addMain(prompt).build()
header := makeHeader("Task", v.textWidth(), args...)
- parts := []string{header}
+ t := tree.Root(header)
+
for _, call := range v.nestedToolCalls {
- parts = append(parts, call.View())
+ t.Child(call.View())
}
+ parts := []string{
+ t.Enumerator(tree.RoundedEnumerator).String(),
+ }
if v.result.ToolCallID == "" {
v.spinning = true
parts = append(parts, v.anim.View())
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index 15c1d6d143c8c7d0622413872c0f72b9949d6210..333f85864c3a5ae48e7cf8ff8303dbb587c43d39 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -138,18 +138,16 @@ func (m *toolCallCmp) View() string {
box := m.style()
if !m.call.Finished && !m.cancelled {
+ if m.isNested {
+ return box.Render(m.renderPending())
+ }
return box.PaddingLeft(1).Render(m.renderPending())
}
r := registry.lookup(m.call.Name)
if m.isNested {
- return box.Render(
- lipgloss.JoinHorizontal(lipgloss.Left,
- " └ ",
- r.Render(m),
- ),
- )
+ return box.Render(r.Render(m))
}
return box.PaddingLeft(1).Render(r.Render(m))
}
@@ -212,9 +210,6 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
// renderPending displays the tool name with a loading animation for pending tool calls
func (m *toolCallCmp) renderPending() string {
- if m.isNested {
- return fmt.Sprintf("└ %s: %s", prettifyToolName(m.call.Name), m.anim.View())
- }
return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
}
diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go
index db1eeafc973f85218b36750c79d337bf9ed41e02..4e534fed54d8112649bd785f112b29ce796ec394 100644
--- a/internal/tui/components/core/list/keys.go
+++ b/internal/tui/components/core/list/keys.go
@@ -1,6 +1,9 @@
package list
-import "github.com/charmbracelet/bubbles/v2/key"
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
type KeyMap struct {
Down,
@@ -12,8 +15,7 @@ type KeyMap struct {
HalfPageDown,
HalfPageUp,
Home,
- End,
- Submit key.Binding
+ End key.Binding
}
func DefaultKeymap() KeyMap {
@@ -48,23 +50,24 @@ func DefaultKeymap() KeyMap {
End: key.NewBinding(
key.WithKeys("shift+g", "end"),
),
- Submit: key.NewBinding(
- key.WithKeys("enter", "space"),
- key.WithHelp("enter/space", "select"),
- ),
}
}
// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding { return nil }
+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("up", "down"),
- key.WithHelp("↓↑", "navigate"),
- ),
- k.Submit,
+ k.Up,
+ k.Down,
}
}
From f1544004b32d2b6a640ee0a339ddb6220d418a84 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Tue, 27 May 2025 14:57:22 +0200
Subject: [PATCH 21/73] wip dialogs
---
cspell.json | 1 +
go.mod | 18 +-
go.sum | 36 +-
internal/tui/components/anim/anim.go | 6 +-
internal/tui/components/chat/editor.go | 27 +-
internal/tui/components/chat/list.go | 6 +-
.../tui/components/chat/messages/messages.go | 10 +-
.../tui/components/chat/messages/renderer.go | 2 +-
internal/tui/components/chat/messages/tool.go | 10 +-
internal/tui/components/chat/sidebar.go | 36 +-
internal/tui/components/core/list/list.go | 13 +-
internal/tui/components/core/status.go | 40 +-
internal/tui/components/dialog/arguments.go | 13 +-
internal/tui/components/dialog/commands.go | 18 +-
internal/tui/components/dialog/complete.go | 22 +-
internal/tui/components/dialog/filepicker.go | 6 +-
internal/tui/components/dialog/help.go | 26 +-
internal/tui/components/dialog/models.go | 16 +-
internal/tui/components/dialog/permission.go | 4 +-
internal/tui/components/dialog/session.go | 28 +-
internal/tui/components/dialog/theme.go | 30 +-
.../components/dialogs/commands/arguments.go | 16 +
.../components/dialogs/commands/commands.go | 135 ++
.../tui/components/dialogs/commands/item.go | 55 +
.../tui/components/dialogs/commands/loader.go | 204 ++
internal/tui/components/dialogs/dialogs.go | 164 ++
internal/tui/components/dialogs/keys.go | 37 +
internal/tui/components/dialogs/quit/keys.go | 59 +
.../{dialog => dialogs/quit}/quit.go | 106 +-
internal/tui/components/logs/details.go | 4 +-
internal/tui/components/logs/table.go | 4 +-
internal/tui/components/util/simple-list.go | 18 +-
internal/tui/keys.go | 75 +
internal/tui/layout/container.go | 19 +-
internal/tui/layout/layout.go | 4 +
internal/tui/layout/split.go | 49 +-
internal/tui/page/chat.go | 15 +-
internal/tui/page/logs.go | 14 +-
internal/tui/tui.go | 1672 ++++++++---------
internal/tui/util/util.go | 2 +-
40 files changed, 1926 insertions(+), 1094 deletions(-)
create mode 100644 cspell.json
create mode 100644 internal/tui/components/dialogs/commands/arguments.go
create mode 100644 internal/tui/components/dialogs/commands/commands.go
create mode 100644 internal/tui/components/dialogs/commands/item.go
create mode 100644 internal/tui/components/dialogs/commands/loader.go
create mode 100644 internal/tui/components/dialogs/dialogs.go
create mode 100644 internal/tui/components/dialogs/keys.go
create mode 100644 internal/tui/components/dialogs/quit/keys.go
rename internal/tui/components/{dialog => dialogs/quit}/quit.go (51%)
create mode 100644 internal/tui/keys.go
diff --git a/cspell.json b/cspell.json
new file mode 100644
index 0000000000000000000000000000000000000000..b7dbd552ca81fc12eeb287709e68c150a3b8f6f7
--- /dev/null
+++ b/cspell.json
@@ -0,0 +1 @@
+{"flagWords":[],"version":"0.2","words":["opencode","charmbracelet","lipgloss","bubbletea"],"language":"en"}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 2891cac0969dd2c7f9ae6b512cb0d42130ffe2c0..30cf44c417849d7902bc7092a47f5bd925759fc5 100644
--- a/go.mod
+++ b/go.mod
@@ -11,11 +11,11 @@ require (
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/v2 v2.0.0-beta.1
- github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1
- github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6
- github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40
- github.com/charmbracelet/x/ansi v0.9.2
+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c
+ 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.20250516160309-24eee56f89fa
github.com/fsnotify/fsnotify v1.8.0
github.com/go-logfmt/logfmt v0.6.0
github.com/google/uuid v1.6.0
@@ -57,12 +57,12 @@ require (
github.com/aws/smithy-go v1.20.3 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
- github.com/charmbracelet/colorprofile v0.3.0 // indirect
- github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da // 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-20250327172914-2fdc97757edf // indirect
- github.com/charmbracelet/x/input v0.3.4 // indirect
+ 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.0 // 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
diff --git a/go.sum b/go.sum
index dfa16aaaba641df5043e9d2af89b626cc34bfc17..2cf341113db9f55d2127cbdba61c679cc4bdfe8d 100644
--- a/go.sum
+++ b/go.sum
@@ -68,30 +68,30 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
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/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc=
-github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
-github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
-github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6 h1:AKhOV8dSRU3KpqMgpGME9JU7ouumB2S6hMmD6PRJeTc=
-github.com/charmbracelet/glamour/v2 v2.0.0-20250513163904-eeeced3bb3c6/go.mod h1:7xBAUTCSADx9mHG0uBf4NDoVpYxMzIQ2j/NMLGdFsFM=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40 h1:SxOUomYAVo5zh+6WCH1bGshlAnSKP0ZeovI0FHAl9kg=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250513162854-28902d027c40/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
-github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY=
-github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da h1:8MGKD5WBtuzfXglq0CnyzVSwGojv57X+H46OL9OUyRA=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250326144200-0875329e71da/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck=
+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.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk=
+github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/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/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-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
-github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
-github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
+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.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
-github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
+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=
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index 8c8276de23600147bbae7ab3cef1483cd05b2904..fa6dabaccb1b4375dcb253c566537840a13b056a 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -239,7 +239,7 @@ func (a *anim) updateChars(chars *[]cyclingChar) {
}
// View renders the animation.
-func (a anim) View() string {
+func (a anim) View() tea.View {
t := theme.CurrentTheme()
var b strings.Builder
@@ -259,10 +259,10 @@ func (a anim) View() string {
textStyle.Render(string(c.currentValue)),
)
}
- return b.String() + textStyle.Render(a.ellipsis.View())
+ return tea.NewView(b.String() + textStyle.Render(a.ellipsis.View()))
}
- return b.String()
+ return tea.NewView(b.String())
}
func makeGradientRamp(length int) []color.Color {
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
index 339b6dd23bad94849dcbf0d2df7aab341a16d295..430b0b4cf3f90cd399cc8fd7be73761e9cd77e92 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor.go
@@ -26,6 +26,7 @@ import (
type editorCmp struct {
width int
height int
+ x, y int
app *app.App
session session.Session
textarea textarea.Model
@@ -116,7 +117,7 @@ func (m *editorCmp) openEditor() tea.Cmd {
}
func (m *editorCmp) Init() tea.Cmd {
- return textarea.Blink
+ return nil
}
func (m *editorCmp) send() tea.Cmd {
@@ -212,7 +213,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
-func (m *editorCmp) View() string {
+func (m *editorCmp) View() tea.View {
t := theme.CurrentTheme()
// Style the prompt with theme colors
@@ -221,16 +222,23 @@ func (m *editorCmp) View() string {
Bold(true).
Foreground(t.Primary())
+ cursor := m.textarea.Cursor()
+ cursor.X = m.textarea.Cursor().X + m.x + 2
+ cursor.Y = m.textarea.Cursor().Y + m.y + 1
if len(m.attachments) == 0 {
- return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
+ view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
+ view.SetCursor(cursor)
+ return view
}
m.textarea.SetHeight(m.height - 1)
- return lipgloss.JoinVertical(lipgloss.Top,
+ view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top,
m.attachmentsContent(),
lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
m.textarea.View(),
),
- )
+ ))
+ view.SetCursor(cursor)
+ return view
}
func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -275,6 +283,12 @@ func (m *editorCmp) BindingKeys() []key.Binding {
return bindings
}
+func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
+ m.x = x
+ m.y = y
+ return nil
+}
+
func CreateTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.Background()
@@ -297,11 +311,12 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
s.Focused = f
s.Blurred = b
- ta.Styles = s
+ ta.SetStyles(s)
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
+ ta.SetVirtualCursor(false)
if existing != nil {
ta.SetValue(existing.Value())
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index e039e259f3223a5c3f6ef61cab4d7c77a228c062..732e1d51d231720a3af4bd505d799c8ff6e23ea7 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -104,11 +104,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// View renders the message list or an initial screen if empty.
-func (m *messageListCmp) View() string {
+func (m *messageListCmp) View() tea.View {
if len(m.listCmp.Items()) == 0 {
- return initialScreen()
+ return tea.NewView(initialScreen())
}
- return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
+ return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View().String()))
}
// handleChildSession handles messages from child sessions (agent tools).
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index b047af6bf36dc800e2755149d906f3ad2ee32a4c..f5420bacf923ee2cae5e18ea90c20d7950f36d3e 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -94,20 +94,20 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View renders the message component based on its current state.
// Returns different views for spinning, user, and assistant messages.
-func (m *messageCmp) View() string {
+func (m *messageCmp) View() tea.View {
if m.spinning {
- return m.style().PaddingLeft(1).Render(m.anim.View())
+ 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 m.renderUserMessage()
+ return tea.NewView(m.renderUserMessage())
default:
- return m.renderAssistantMessage()
+ return tea.NewView(m.renderAssistantMessage())
}
}
- return "Unknown Message"
+ return tea.NewView(m.style().Render("No message content"))
}
// GetMessage returns the underlying message data
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index e559ff11f1c2345f61733ae21b4f1c1a2035f65f..3c86c2be1c063f45f36ac2cdd84b600da820856a 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -507,7 +507,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
}
if v.result.ToolCallID == "" {
v.spinning = true
- parts = append(parts, v.anim.View())
+ parts = append(parts, v.anim.View().String())
} else {
v.spinning = false
}
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index 333f85864c3a5ae48e7cf8ff8303dbb587c43d39..2f23146a26ba204f895bcac9ee61cb8360f91e2b 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -134,22 +134,22 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 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() string {
+func (m *toolCallCmp) View() tea.View {
box := m.style()
if !m.call.Finished && !m.cancelled {
if m.isNested {
- return box.Render(m.renderPending())
+ return tea.NewView(box.Render(m.renderPending()))
}
- return box.PaddingLeft(1).Render(m.renderPending())
+ return tea.NewView(box.PaddingLeft(1).Render(m.renderPending()))
}
r := registry.lookup(m.call.Name)
if m.isNested {
- return box.Render(r.Render(m))
+ return tea.NewView(box.Render(r.Render(m)))
}
- return box.PaddingLeft(1).Render(r.Render(m))
+ return tea.NewView(box.PaddingLeft(1).Render(r.Render(m)))
}
// State management methods
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
index ce643d20076cd0f28c43dd18b10c47fea09facd9..5d631364a7402f05e2233d28d244c86a0398a3e7 100644
--- a/internal/tui/components/chat/sidebar.go
+++ b/internal/tui/components/chat/sidebar.go
@@ -82,26 +82,28 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-func (m *sidebarCmp) View() string {
+func (m *sidebarCmp) View() tea.View {
baseStyle := styles.BaseStyle()
- return baseStyle.
- Width(m.width).
- PaddingLeft(4).
- PaddingRight(2).
- Height(m.height - 1).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(),
- " ",
- m.sessionSection(),
- " ",
- lspsConfigured(),
- " ",
- m.modifiedFiles(),
+ return tea.NewView(
+ baseStyle.
+ Width(m.width).
+ PaddingLeft(4).
+ PaddingRight(2).
+ Height(m.height - 1).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(),
+ " ",
+ m.sessionSection(),
+ " ",
+ lspsConfigured(),
+ " ",
+ m.modifiedFiles(),
+ ),
),
- )
+ )
}
func (m *sidebarCmp) sessionSection() string {
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 341b94b1ca5998f974f60f2d0a922ec1b16bd6f7..1dd00a01b30a890dd123a670e8ceb4cf4277a3fd 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -269,14 +269,19 @@ func (m *model) scrollUp(amount int) {
// 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() string {
+func (m *model) View() tea.View {
if m.viewState.height == 0 || m.viewState.width == 0 {
- return ""
+ return tea.NewView("") // No content to display
}
if m.renderState.needsRerender {
m.renderVisible()
}
- return lipgloss.NewStyle().Padding(m.padding...).Height(m.viewState.height).Render(m.viewState.content)
+ return tea.NewView(
+ lipgloss.NewStyle().
+ Padding(m.padding...).
+ Height(m.viewState.height).
+ Render(m.viewState.content),
+ )
}
// Items returns a copy of all items in the list.
@@ -642,7 +647,7 @@ func (m *model) rerenderItem(inx int) {
// getItemLines converts an item to its rendered lines, including any gap spacing.
func (m *model) getItemLines(item util.Model) []string {
- itemLines := strings.Split(item.View(), "\n")
+ itemLines := strings.Split(item.View().String(), "\n")
if m.gapSize > 0 {
gap := make([]string, m.gapSize)
itemLines = append(itemLines, gap...)
diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go
index 037bd7417897a75f62feb16eca90b81a82360648..9d01d835c1a581aa52de241b4598efc8e3171d09 100644
--- a/internal/tui/components/core/status.go
+++ b/internal/tui/components/core/status.go
@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
+ "github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/lsp/protocol"
"github.com/opencode-ai/opencode/internal/pubsub"
@@ -47,6 +48,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
+
+ // Handle sesson messages
case chat.SessionSelectedMsg:
m.session = msg
case chat.SessionClearedMsg:
@@ -57,6 +60,8 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.session = msg.Payload
}
}
+
+ // Handle status info
case util.InfoMsg:
m.info = msg
ttl := msg.TTL
@@ -66,6 +71,37 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.clearMessageCmd(ttl)
case util.ClearStatusMsg:
m.info = util.InfoMsg{}
+
+ // Handle persistant 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
}
@@ -116,7 +152,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
}
-func (m statusCmp) View() string {
+func (m statusCmp) View() tea.View {
t := theme.CurrentTheme()
modelID := config.Get().Agents[config.AgentCoder].Model
model := models.SupportedModels[modelID]
@@ -176,7 +212,7 @@ func (m statusCmp) View() string {
status += diagnostics
status += m.model()
- return status
+ return tea.NewView(status)
}
func (m *statusCmp) projectDiagnostics() string {
diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go
index 109a389954b351c31e81b2e034c134202d4d7e0f..5c289ddd25bd44f6d4ae070eef73b995ed3fd00b 100644
--- a/internal/tui/components/dialog/arguments.go
+++ b/internal/tui/components/dialog/arguments.go
@@ -73,12 +73,13 @@ func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) Mu
ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
ti.SetWidth(40)
ti.Prompt = ""
- ti.Styles.Focused.Placeholder = ti.Styles.Focused.Placeholder.Background(t.Background())
- ti.Styles.Blurred.Placeholder = ti.Styles.Blurred.Placeholder.Background(t.Background())
- ti.Styles.Focused.Suggestion = ti.Styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
- ti.Styles.Blurred.Suggestion = ti.Styles.Blurred.Suggestion.Background(t.Background())
- ti.Styles.Focused.Text = ti.Styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
- ti.Styles.Blurred.Text = ti.Styles.Blurred.Text.Background(t.Background())
+ styles := ti.Styles()
+ styles.Focused.Placeholder = styles.Focused.Placeholder.Background(t.Background())
+ styles.Blurred.Placeholder = styles.Blurred.Placeholder.Background(t.Background())
+ styles.Focused.Suggestion = styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
+ styles.Blurred.Suggestion = styles.Blurred.Suggestion.Background(t.Background())
+ styles.Focused.Text = styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
+ styles.Blurred.Text = styles.Blurred.Text.Background(t.Background())
// Only focus the first input initially
if i == 0 {
diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go
index c89e8ffa1f2af7908651104cd021a2ea5ebed6c9..1e60d3ed1f317c2729ce33ae3e62f5867a2245e6 100644
--- a/internal/tui/components/dialog/commands.go
+++ b/internal/tui/components/dialog/commands.go
@@ -114,7 +114,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, tea.Batch(cmds...)
}
-func (c *commandDialogCmp) View() string {
+func (c *commandDialogCmp) View() tea.View {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
@@ -146,16 +146,18 @@ func (c *commandDialogCmp) View() string {
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
- baseStyle.Width(maxWidth).Render(c.listView.View()),
+ baseStyle.Width(maxWidth).Render(c.listView.View().String()),
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)
+ return tea.NewView(
+ 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 {
diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go
index 4fbd6e3eee9338ca79f02f90e3e111cbf1339541..d5cf1519a91c1cd5c6e3572cb33f43f84d64b7e6 100644
--- a/internal/tui/components/dialog/complete.go
+++ b/internal/tui/components/dialog/complete.go
@@ -202,7 +202,7 @@ func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, tea.Batch(cmds...)
}
-func (c *completionDialogCmp) View() string {
+func (c *completionDialogCmp) View() tea.View {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
@@ -219,15 +219,17 @@ func (c *completionDialogCmp) View() string {
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())
+ return tea.NewView(
+ 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().String()),
+ )
}
func (c *completionDialogCmp) SetWidth(width int) {
diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go
index 1b09d53a56542255fd83248cdc1b39ebbb2db24e..8edb182701a6294521098550e40ee661e727d919 100644
--- a/internal/tui/components/dialog/filepicker.go
+++ b/internal/tui/components/dialog/filepicker.go
@@ -258,7 +258,7 @@ func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
return f, util.CmdHandler(AttachmentAddedMsg{attachment})
}
-func (f *filepickerCmp) View() string {
+func (f *filepickerCmp) View() tea.View {
t := theme.CurrentTheme()
const maxVisibleDirs = 20
const maxWidth = 80
@@ -349,7 +349,9 @@ func (f *filepickerCmp) View() string {
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4)
- return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
+ return tea.NewView(
+ lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
+ )
}
type FilepickerCmp interface {
diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go
index 63416f4250548c51cce14308b6efac617221a2c2..549c2a476fa43da14a36c5d942e894d83b6f79ac 100644
--- a/internal/tui/components/dialog/help.go
+++ b/internal/tui/components/dialog/help.go
@@ -166,7 +166,7 @@ func (h *helpCmp) render() string {
return content
}
-func (h *helpCmp) View() string {
+func (h *helpCmp) View() tea.View {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
@@ -177,18 +177,20 @@ func (h *helpCmp) View() string {
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,
+ return tea.NewView(
+ 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 {
diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go
index 25b45839754bae91402e8a8cff529b55b9e6f6a6..d14ffbf9241d749095aa9a03a8e00b33489ba4b4 100644
--- a/internal/tui/components/dialog/models.go
+++ b/internal/tui/components/dialog/models.go
@@ -185,7 +185,7 @@ func (m *modelDialogCmp) switchProvider(offset int) {
m.setupModelsForProvider(m.provider)
}
-func (m *modelDialogCmp) View() string {
+func (m *modelDialogCmp) View() tea.View {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
@@ -220,12 +220,14 @@ func (m *modelDialogCmp) View() string {
scrollIndicator,
)
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4).
- Render(content)
+ return tea.NewView(
+ 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 {
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index 3db2ea2125e37a58e68a8f9eab8ee65257fb2c9a..e258dbc24099414309faef78ac8a6aefe204c989 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -437,8 +437,8 @@ func (p *permissionDialogCmp) render() string {
)
}
-func (p *permissionDialogCmp) View() string {
- return p.render()
+func (p *permissionDialogCmp) View() tea.View {
+ return tea.NewView(p.render())
}
func (p *permissionDialogCmp) BindingKeys() []key.Binding {
diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go
index 8fc704711a8017241cc0093f1f7dd22f363c54af..15a118d6efae995182c3cefa1a4b81ddaa0e5e28 100644
--- a/internal/tui/components/dialog/session.go
+++ b/internal/tui/components/dialog/session.go
@@ -105,17 +105,19 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, nil
}
-func (s *sessionDialogCmp) View() string {
+func (s *sessionDialogCmp) View() tea.View {
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")
+ return tea.NewView(
+ 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
@@ -177,11 +179,13 @@ func (s *sessionDialogCmp) View() string {
baseStyle.Width(maxWidth).Render(""),
)
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Render(content)
+ return tea.NewView(
+ baseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(t.Background()).
+ BorderForeground(t.TextMuted()).
+ Render(content),
+ )
}
func (s *sessionDialogCmp) BindingKeys() []key.Binding {
diff --git a/internal/tui/components/dialog/theme.go b/internal/tui/components/dialog/theme.go
index bdd89a9dd82dc31040a19027535b9ca914263124..c5faf6c902d6bdf2f935abb2418d3adb4558def9 100644
--- a/internal/tui/components/dialog/theme.go
+++ b/internal/tui/components/dialog/theme.go
@@ -122,17 +122,19 @@ func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return t, nil
}
-func (t *themeDialogCmp) View() string {
+func (t *themeDialogCmp) View() tea.View {
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")
+ return tea.NewView(
+ 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
@@ -175,12 +177,14 @@ func (t *themeDialogCmp) View() string {
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)
+ return tea.NewView(
+ 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 {
diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go
new file mode 100644
index 0000000000000000000000000000000000000000..02ecf747c56aa93c8b65763d2931be2030e5975b
--- /dev/null
+++ b/internal/tui/components/dialogs/commands/arguments.go
@@ -0,0 +1,16 @@
+package commands
+
+// 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
+}
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
new file mode 100644
index 0000000000000000000000000000000000000000..7a34fdd511bee2ade344ae44ca3652e0cecbe2c3
--- /dev/null
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -0,0 +1,135 @@
+package commands
+
+import (
+ "github.com/charmbracelet/bubbles/v2/textinput"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+
+ "github.com/opencode-ai/opencode/internal/logging"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
+ "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 (
+ id dialogs.DialogID = "commands"
+)
+
+// Command represents a command that can be executed
+type Command struct {
+ ID string
+ Title string
+ Description string
+ 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
+ input textinput.Model
+ oldCursor tea.Cursor
+}
+
+func NewCommandDialog() CommandsDialog {
+ ti := textinput.New()
+ ti.Placeholder = "Type a command or search..."
+ ti.SetVirtualCursor(false)
+ ti.Focus()
+ ti.SetWidth(60 - 7)
+ commandList := list.New()
+ return &commandDialogCmp{
+ commandList: commandList,
+ width: 60,
+ input: ti,
+ }
+}
+
+func (c *commandDialogCmp) Init() tea.Cmd {
+ logging.Info("Initializing commands dialog")
+ commands, err := LoadCustomCommands()
+ if err != nil {
+ return util.ReportError(err)
+ }
+ logging.Info("Commands loaded", "count", len(commands))
+
+ commandItems := make([]util.Model, 0, len(commands))
+
+ for _, cmd := range commands {
+ commandItems = append(commandItems, NewCommandItem(cmd))
+ }
+ c.commandList.SetItems(commandItems)
+ 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(60, min(len(c.commandList.Items())*2, c.wHeight/2))
+ }
+ u, cmd := c.input.Update(msg)
+ c.input = u
+ return c, cmd
+}
+
+func (c *commandDialogCmp) View() tea.View {
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ c.inputStyle().Render(c.input.View()),
+ c.commandList.View().String(),
+ )
+
+ v := tea.NewView(c.style().Render(content))
+ v.SetCursor(c.getCursor())
+ return v
+}
+
+func (c *commandDialogCmp) getCursor() *tea.Cursor {
+ cursor := c.input.Cursor()
+ offset := 10 + 1
+ cursor.Y += offset
+ _, col := c.Position()
+ cursor.X = c.input.Cursor().X + col + 2
+ return cursor
+}
+
+func (c *commandDialogCmp) inputStyle() lipgloss.Style {
+ t := theme.CurrentTheme()
+ return styles.BaseStyle().
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(t.TextMuted()).
+ BorderBackground(t.Background()).
+ BorderBottom(true)
+}
+
+func (c *commandDialogCmp) style() lipgloss.Style {
+ t := theme.CurrentTheme()
+ return styles.BaseStyle().
+ Width(c.width).
+ Padding(0, 1, 1, 1).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(t.Background()).
+ BorderForeground(t.TextMuted())
+}
+
+func (q *commandDialogCmp) Position() (int, int) {
+ row := 10
+ col := q.wWidth / 2
+ col -= q.width / 2
+ return row, col
+}
+
+func (c *commandDialogCmp) ID() dialogs.DialogID {
+ return id
+}
diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go
new file mode 100644
index 0000000000000000000000000000000000000000..36c00199da323abc7038478079f353c1c2279820
--- /dev/null
+++ b/internal/tui/components/dialogs/commands/item.go
@@ -0,0 +1,55 @@
+package commands
+
+import (
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+type CommandItem interface {
+ util.Model
+ layout.Focusable
+}
+
+type commandItem struct {
+ command Command
+ focus bool
+}
+
+func NewCommandItem(command Command) CommandItem {
+ return &commandItem{
+ command: command,
+ }
+}
+
+// Init implements CommandItem.
+func (c *commandItem) Init() tea.Cmd {
+ return nil
+}
+
+// Update implements CommandItem.
+func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
+ return c, nil
+}
+
+// View implements CommandItem.
+func (c *commandItem) View() tea.View {
+ return tea.NewView(c.command.Title)
+}
+
+// Blur implements CommandItem.
+func (c *commandItem) Blur() tea.Cmd {
+ c.focus = false
+ return nil
+}
+
+// Focus implements CommandItem.
+func (c *commandItem) Focus() tea.Cmd {
+ c.focus = true
+ return nil
+}
+
+// IsFocused implements CommandItem.
+func (c *commandItem) IsFocused() bool {
+ return c.focus
+}
diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go
new file mode 100644
index 0000000000000000000000000000000000000000..8767d6bf7b0c3a3e901dcdebd029cc29d7da4ed6
--- /dev/null
+++ b/internal/tui/components/dialogs/commands/loader.go
@@ -0,0 +1,204 @@
+package commands
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/opencode-ai/opencode/internal/config"
+ "github.com/opencode-ai/opencode/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, ".opencode", "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, "opencode", "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,
+ Args: nil,
+ })
+ }
+}
+
+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, 0755)
+ }
+ return nil
+}
+
+func isMarkdownFile(name string) bool {
+ return strings.HasSuffix(strings.ToLower(name), ".md")
+}
+
+type CommandRunCustomMsg struct {
+ Content string
+ Args map[string]string
+}
diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go
new file mode 100644
index 0000000000000000000000000000000000000000..9862388fc16af59b0dc3ac63a8485cc02924370d
--- /dev/null
+++ b/internal/tui/components/dialogs/dialogs.go
@@ -0,0 +1,164 @@
+package dialogs
+
+import (
+ "slices"
+
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+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
+}
+
+// AbsolutePositionable is an interface for components that can set their position
+type AbsolutePositionable interface {
+ SetPosition(x, y int)
+}
+
+// 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
+}
+
+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
+ case tea.KeyPressMsg:
+ if key.Matches(msg, d.keymap.Close) {
+ return d, util.CmdHandler(CloseDialogMsg{})
+ }
+ }
+ 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 ontop of quit
+ }
+ }
+ // if the dialog is already in thel 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) 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/keys.go b/internal/tui/components/dialogs/keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..34a5aeb4d5b46b52e4ef6968e5c8bc480a2e3819
--- /dev/null
+++ b/internal/tui/components/dialogs/keys.go
@@ -0,0 +1,37 @@
+package dialogs
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/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/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..a2459af696d16ed497565b71775887d7f75f317d
--- /dev/null
+++ b/internal/tui/components/dialogs/quit/keys.go
@@ -0,0 +1,59 @@
+package quit
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+// KeyMap defines the keyboard bindings for the quit dialog.
+type KeyMap struct {
+ LeftRight key.Binding
+ EnterSpace key.Binding
+ Yes key.Binding
+ No key.Binding
+ Tab 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"),
+ ),
+ }
+}
+
+// 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/dialog/quit.go b/internal/tui/components/dialogs/quit/quit.go
similarity index 51%
rename from internal/tui/components/dialog/quit.go
rename to internal/tui/components/dialogs/quit/quit.go
index c1c7a5b1441eec5d2529f2aeafd4f068bd47711c..211bac3c88258dae43b791080e6570b58192b5db 100644
--- a/internal/tui/components/dialog/quit.go
+++ b/internal/tui/components/dialogs/quit/quit.go
@@ -1,87 +1,74 @@
-package dialog
+package quit
import (
- "strings"
-
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"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{}
+const (
+ question = "Are you sure you want to quit?"
+ id dialogs.DialogID = "quit"
+)
+// QuitDialog represents a confirmation dialog for quitting the application.
type QuitDialog interface {
- util.Model
+ dialogs.DialogModel
layout.Bindings
}
type quitDialogCmp struct {
- selectedNo bool
-}
+ wWidth int
+ wHeight int
-type helpMapping struct {
- LeftRight key.Binding
- EnterSpace key.Binding
- Yes key.Binding
- No key.Binding
- Tab key.Binding
+ selectedNo bool // true if "No" button is selected
+ keymap KeyMap
}
-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"),
- ),
+// 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, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
+ case key.Matches(msg, q.keymap.LeftRight) || key.Matches(msg, q.keymap.Tab):
q.selectedNo = !q.selectedNo
return q, nil
- case key.Matches(msg, helpKeys.EnterSpace):
+ case key.Matches(msg, q.keymap.EnterSpace):
if !q.selectedNo {
return q, tea.Quit
}
- return q, util.CmdHandler(CloseQuitMsg{})
- case key.Matches(msg, helpKeys.Yes):
+ return q, util.CmdHandler(dialogs.CloseDialogMsg{})
+ case key.Matches(msg, q.keymap.Yes):
return q, tea.Quit
- case key.Matches(msg, helpKeys.No):
- return q, util.CmdHandler(CloseQuitMsg{})
+ case key.Matches(msg, q.keymap.No):
+ return q, util.CmdHandler(dialogs.CloseDialogMsg{})
}
}
return q, nil
}
-func (q *quitDialogCmp) View() string {
+// View renders the quit dialog with Yes/No buttons.
+func (q *quitDialogCmp) View() tea.View {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
@@ -100,13 +87,9 @@ func (q *quitDialogCmp) View() string {
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
- }
+ buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
+ lipgloss.JoinHorizontal(lipgloss.Center, yesButton, spacerStyle.Render(" "), noButton),
+ )
content := baseStyle.Render(
lipgloss.JoinVertical(
@@ -123,17 +106,24 @@ func (q *quitDialogCmp) View() string {
BorderBackground(t.Background()).
BorderForeground(t.TextMuted())
- return quitDialogStyle.
- Width(lipgloss.Width(content) + quitDialogStyle.GetHorizontalFrameSize()).
- Render(content)
+ return tea.NewView(
+ quitDialogStyle.Render(content),
+ )
}
func (q *quitDialogCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(helpKeys)
+ return layout.KeyMapToSlice(q.keymap)
}
-func NewQuitCmp() QuitDialog {
- return &quitDialogCmp{
- selectedNo: true,
- }
+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 id
}
diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go
index 875395cfe5c535eba8be73a3eccc92f4f666dc53..9ee743ca680eab852fb30a059983bbc5f5427f2c 100644
--- a/internal/tui/components/logs/details.go
+++ b/internal/tui/components/logs/details.go
@@ -114,8 +114,8 @@ func getLevelStyle(level string) lipgloss.Style {
}
}
-func (i *detailCmp) View() string {
- return i.viewport.View()
+func (i *detailCmp) View() tea.View {
+ return tea.NewView(i.viewport.View())
}
func (i *detailCmp) GetSize() (int, int) {
diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go
index 689ea087406cb64be62d06c4beff71fce6483304..791d104bc0e7127136420bcd2519815709f5b79f 100644
--- a/internal/tui/components/logs/table.go
+++ b/internal/tui/components/logs/table.go
@@ -60,12 +60,12 @@ func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return i, tea.Batch(cmds...)
}
-func (i *tableCmp) View() string {
+func (i *tableCmp) View() tea.View {
t := theme.CurrentTheme()
defaultStyles := table.DefaultStyles()
defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
i.table.SetStyles(defaultStyles)
- return i.table.View()
+ return tea.NewView(i.table.View())
}
func (i *tableCmp) GetSize() (int, int) {
diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go
index a944925095a61bc247ff9534bcadff1a1609c01f..36df48394e0792d056deab6380d6a1003cbe6b55 100644
--- a/internal/tui/components/util/simple-list.go
+++ b/internal/tui/components/util/simple-list.go
@@ -110,7 +110,7 @@ func (c *simpleListCmp[T]) SetMaxWidth(width int) {
c.maxWidth = width
}
-func (c *simpleListCmp[T]) View() string {
+func (c *simpleListCmp[T]) View() tea.View {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
@@ -120,11 +120,13 @@ func (c *simpleListCmp[T]) View() string {
startIdx := 0
if len(items) <= 0 {
- return baseStyle.
- Background(t.Background()).
- Padding(0, 1).
- Width(maxWidth).
- Render(c.fallbackMsg)
+ return tea.NewView(
+ baseStyle.
+ Background(t.Background()).
+ Padding(0, 1).
+ Width(maxWidth).
+ Render(c.fallbackMsg),
+ )
}
if len(items) > maxVisibleItems {
@@ -146,7 +148,9 @@ func (c *simpleListCmp[T]) View() string {
listItems = append(listItems, title)
}
- return lipgloss.JoinVertical(lipgloss.Left, listItems...)
+ return tea.NewView(
+ lipgloss.JoinVertical(lipgloss.Left, listItems...),
+ )
}
func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..bc836d3dbf1f5bd0e88bc02fb4628e1305f9bcd8
--- /dev/null
+++ b/internal/tui/keys.go
@@ -0,0 +1,75 @@
+package tui
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+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
+}
+
+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"),
+ ),
+
+ 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"),
+ ),
+ }
+}
+
+// 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 81e331c3c27fc2a3fbd3e9010516facce681d683..923f29e6b284086cd00dc52b181d8933d3801eaf 100644
--- a/internal/tui/layout/container.go
+++ b/internal/tui/layout/container.go
@@ -12,11 +12,14 @@ type Container interface {
util.Model
Sizeable
Bindings
+ Positionable
}
type container struct {
width int
height int
+ x, y int
+
content util.Model
// Style options
@@ -42,7 +45,7 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, cmd
}
-func (c *container) View() string {
+func (c *container) View() tea.View {
t := theme.CurrentTheme()
style := lipgloss.NewStyle()
width := c.width
@@ -76,7 +79,10 @@ 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()))
+ view.SetCursor(contentView.Cursor())
+ return view
}
func (c *container) SetSize(width, height int) tea.Cmd {
@@ -115,6 +121,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()
diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go
index 08aa3173ef89230a31962965d7607e4c043829c5..4d01ccc0834f944ebb12f8641ee6f1f2da0ec58d 100644
--- a/internal/tui/layout/layout.go
+++ b/internal/tui/layout/layout.go
@@ -22,6 +22,10 @@ type Bindings interface {
BindingKeys() []key.Binding
}
+type Positionable interface {
+ SetPosition(x, y int) tea.Cmd
+}
+
func KeyMapToSlice(t any) (bindings []key.Binding) {
typ := reflect.TypeOf(t)
if typ.Kind() != reflect.Struct {
diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go
index c40a6a0e79771ec8ea669382a99aa387384abc58..bfd98b5059165f974283b4f3efb7b67713f1a41c 100644
--- a/internal/tui/layout/split.go
+++ b/internal/tui/layout/split.go
@@ -86,17 +86,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 = ""
}
@@ -105,25 +105,33 @@ 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 := theme.CurrentTheme()
- return style.Render(finalView)
- }
+ style := lipgloss.NewStyle().
+ Width(s.width).
+ Height(s.height).
+ Background(t.Background())
- return finalView
+ view := tea.NewView(style.Render(finalView))
+ view.SetCursor(cursor)
+ return view
}
func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
@@ -131,6 +139,7 @@ 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
@@ -151,20 +160,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...)
}
diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go
index 4546b268f9c3b9649ddd6b00f025feb8bf3d092a..92166ca02e9f934db50a226d5b357736031ab4d3 100644
--- a/internal/tui/page/chat.go
+++ b/internal/tui/page/chat.go
@@ -185,7 +185,7 @@ func (p *chatPage) GetSize() (int, int) {
return p.layout.GetSize()
}
-func (p *chatPage) View() string {
+func (p *chatPage) View() tea.View {
layoutView := p.layout.View()
if p.showCompletionDialog {
@@ -195,15 +195,20 @@ func (p *chatPage) View() string {
p.completionDialog.SetWidth(editorWidth)
overlay := p.completionDialog.View()
- layoutView = layout.PlaceOverlay(
+ viewStr := layout.PlaceOverlay(
0,
- layoutHeight-editorHeight-lipgloss.Height(overlay),
- overlay,
- layoutView,
+ layoutHeight-editorHeight-lipgloss.Height(overlay.String()),
+ overlay.String(),
+ layoutView.String(),
false,
)
+
+ view := tea.NewView(viewStr)
+ view.SetCursor(overlay.Cursor())
+ return view
}
+ logging.Info("Cursor in page", "c", layoutView.Cursor())
return layoutView
}
diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go
index 89c69b8654672987b4c49c1dec000fd83fb62031..63613d02d9c222e6f998b571779f43ae13e74668 100644
--- a/internal/tui/page/logs.go
+++ b/internal/tui/page/logs.go
@@ -42,12 +42,16 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, tea.Batch(cmds...)
}
-func (p *logsPage) View() string {
+func (p *logsPage) View() tea.View {
style := styles.BaseStyle().Width(p.width).Height(p.height)
- return style.Render(lipgloss.JoinVertical(lipgloss.Top,
- p.table.View(),
- p.details.View(),
- ))
+ return tea.NewView(
+ style.Render(
+ lipgloss.JoinVertical(lipgloss.Top,
+ p.table.View().String(),
+ p.details.View().String(),
+ ),
+ ),
+ )
}
func (p *logsPage) BindingKeys() []key.Binding {
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 75f90d97b6c4c8117fcd28bbb3f2cfdf96934777..f3fffd9a383d4916698d8275885ed9a43e8b0665 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -1,676 +1,635 @@
package tui
import (
- "context"
- "fmt"
- "strings"
-
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"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/components/dialogs"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
"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"),
-)
+// type startCompactSessionMsg struct{}
type appModel struct {
- width, height int
- currentPage page.PageID
- previousPage page.PageID
- pages map[page.PageID]util.Model
- loadedPages map[page.PageID]bool
- status core.StatusCmp
- app *app.App
- selectedSession session.Session
-
- showPermissions bool
- permissions dialog.PermissionDialogCmp
-
- showHelp bool
- help dialog.HelpCmp
-
- showQuit bool
- quit dialog.QuitDialog
-
- showSessionDialog bool
- sessionDialog dialog.SessionDialog
-
- showCommandDialog bool
- commandDialog dialog.CommandDialog
- commands []dialog.Command
-
- 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
+ width, height int
+ keyMap KeyMap
+
+ currentPage page.PageID
+ previousPage page.PageID
+ pages map[page.PageID]util.Model
+ loadedPages map[page.PageID]bool
+
+ status core.StatusCmp
+
+ app *app.App
+
+ // selectedSession session.Session
+ //
+ // showPermissions bool
+ // permissions dialog.PermissionDialogCmp
+ //
+ // showHelp bool
+ // help dialog.HelpCmp
+ //
+ // showSessionDialog bool
+ // sessionDialog dialog.SessionDialog
+ //
+ // showCommandDialog bool
+ // commandDialog dialog.CommandDialog
+ // commands []dialog.Command
+ //
+ // 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
+
+ // NEW DIALOG
+ dialog dialogs.DialogCmp
}
func (a appModel) Init() tea.Cmd {
var cmds []tea.Cmd
cmd := a.pages[a.currentPage].Init()
- t := theme.CurrentTheme()
- cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
- 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)
+ // 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 {
- shouldShow, err := config.ShouldShowInitDialog()
- if err != nil {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: "Failed to check init status: " + err.Error(),
- }
- }
- return dialog.ShowInitDialogMsg{Show: shouldShow}
- })
+ // cmds = append(cmds, func() tea.Msg {
+ // shouldShow, err := config.ShouldShowInitDialog()
+ // if err != nil {
+ // return util.InfoMsg{
+ // Type: util.InfoTypeError,
+ // Msg: "Failed to check init status: " + err.Error(),
+ // }
+ // }
+ // return dialog.ShowInitDialogMsg{Show: shouldShow}
+ // })
+ t := theme.CurrentTheme()
+ cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
return tea.Batch(cmds...)
}
-func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+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)
- updated, cmd := a.pages[a.currentPage].Update(msg)
- a.pages[a.currentPage] = updated.(util.Model)
- 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)
-
- a.initDialog.SetSize(msg.Width, msg.Height)
-
- 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())
- }
+ return a, a.handleWindowResize(msg)
+ // TODO: remove when refactor is done
+ // 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)
+ // updated, cmd := a.pages[a.currentPage].Update(msg)
+ // a.pages[a.currentPage] = updated.(util.Model)
+ // 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)
+ //
+ // a.initDialog.SetSize(msg.Width, msg.Height)
+ //
+ // 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())
+ // }
+ //
+ // dialog, cmd := a.dialog.Update(msg)
+ // a.dialog = dialog.(dialogs.DialogCmp)
+ // cmds = append(cmds, cmd)
+ //
+ // return a, tea.Batch(cmds...)
+
+ // Dialog messages
+ case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
+ u, dialogCmd := a.dialog.Update(msg)
+ a.dialog = u.(dialogs.DialogCmp)
+ return a, dialogCmd
+
+ // Page change messages
+ case page.PageChangeMsg:
+ return a, a.moveToPage(msg.ID)
- return a, tea.Batch(cmds...)
- // Status
- case util.InfoMsg:
+ // Status Messages
+ case util.InfoMsg, util.ClearStatusMsg:
s, cmd := a.status.Update(msg)
a.status = s.(core.StatusCmp)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
+ // Logs
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)
- }
- }
- case util.ClearStatusMsg:
- s, _ := a.status.Update(msg)
+ // Send to the status component
+ s, cmd := a.status.Update(msg)
a.status = s.(core.StatusCmp)
+ cmds = append(cmds, cmd)
- // Permission
- case pubsub.Event[permission.PermissionRequest]:
- a.showPermissions = true
- return a, a.permissions.SetPermissions(msg.Payload)
- case dialog.PermissionResponseMsg:
- var cmd tea.Cmd
- switch msg.Action {
- case dialog.PermissionAllow:
- a.app.Permissions.Grant(msg.Permission)
- case dialog.PermissionAllowForSession:
- a.app.Permissions.GrantPersistant(msg.Permission)
- case dialog.PermissionDeny:
- a.app.Permissions.Deny(msg.Permission)
+ // If the current page is logs, update the logs view
+ if a.currentPage == page.LogsPage {
+ updated, cmd := a.pages[a.currentPage].Update(msg)
+ a.pages[a.currentPage] = updated.(util.Model)
+ cmds = append(cmds, cmd)
}
- 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..."
+ return a, tea.Batch(cmds...)
- if a.selectedSession.ID == "" {
- a.isCompacting = false
- return a, util.ReportWarn("No active session to summarize")
- }
+ // // Permission
+ // case pubsub.Event[permission.PermissionRequest]:
+ // a.showPermissions = true
+ // return a, a.permissions.SetPermissions(msg.Payload)
+ // case dialog.PermissionResponseMsg:
+ // var cmd tea.Cmd
+ // switch msg.Action {
+ // case dialog.PermissionAllow:
+ // a.app.Permissions.Grant(msg.Permission)
+ // case dialog.PermissionAllowForSession:
+ // a.app.Permissions.GrantPersistant(msg.Permission)
+ // case dialog.PermissionDeny:
+ // a.app.Permissions.Deny(msg.Permission)
+ // }
+ // a.showPermissions = false
+ // return a, cmd
+ //
+ // // Theme changed
+ // case dialog.ThemeChangedMsg:
+ // updated, cmd := a.pages[a.currentPage].Update(msg)
+ // a.pages[a.currentPage] = updated.(util.Model)
+ // a.showThemeDialog = false
+ // return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
+ //
+ // 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
+ // }
+ //
+ // 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.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)
+ // }
+ //
+ // 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)
+ // }
+ // }
+ // } 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, nil
+ //
+ case tea.KeyPressMsg:
+ return a, a.handleKeyPressMsg(msg)
+ // if a.dialog.HasDialogs() {
+ // u, dialogCmd := a.dialog.Update(msg)
+ // a.dialog = u.(dialogs.DialogCmp)
+ // return a, dialogCmd
+ // }
+ // // 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 {
+ // case key.Matches(msg, keys.Quit):
+ // // TODO: fix this after testing
+ // // 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, util.CmdHandler(dialogs.OpenDialogMsg{
+ // Model: quit.NewQuitDialog(),
+ // })
+ // case key.Matches(msg, keys.SwitchSession):
+ // if a.currentPage == page.ChatPage && !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.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, util.CmdHandler(dialogs.OpenDialogMsg{
+ // Model: commands.NewCommandDialog(),
+ // })
+ // case key.Matches(msg, keys.Models):
+ // if a.showModelDialog {
+ // a.showModelDialog = false
+ // return a, nil
+ // }
+ // if a.currentPage == page.ChatPage && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+ // a.showModelDialog = true
+ // return a, nil
+ // }
+ // return a, nil
+ // case key.Matches(msg, keys.SwitchTheme):
+ // if !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.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):
+ // a.showHelp = !a.showHelp
+ // return a, nil
+ // case key.Matches(msg, helpEsc):
+ // if a.app.CoderAgent.IsBusy() {
+ // 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:
+ // u, dialogCmd := a.dialog.Update(msg)
+ // a.dialog = u.(dialogs.DialogCmp)
+ // cmds = append(cmds, dialogCmd)
+ // f, filepickerCmd := a.filepicker.Update(msg)
+ // a.filepicker = f.(dialog.FilepickerCmp)
+ // cmds = append(cmds, filepickerCmd)
+ // }
+
+ // 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.KeyPressMsg); 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.KeyPressMsg); ok {
+ // return a, tea.Batch(cmds...)
+ // }
+ // }
+ //
+ // 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.KeyPressMsg); ok {
+ // return a, 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.KeyPressMsg); ok {
+ // return a, tea.Batch(cmds...)
+ // }
+ // }
+ //
+ // 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.KeyPressMsg); ok {
+ // return a, tea.Batch(cmds...)
+ // }
+ // }
+ //
+ // 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.KeyPressMsg); ok {
+ // return a, tea.Batch(cmds...)
+ // }
+ // }
+ //
+ // 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.KeyPressMsg); ok {
+ // return a, tea.Batch(cmds...)
+ // }
+ }
+ //
+ s, _ := a.status.Update(msg)
+ a.status = s.(core.StatusCmp)
+ updated, cmd := a.pages[a.currentPage].Update(msg)
+ a.pages[a.currentPage] = updated.(util.Model)
+ cmds = append(cmds, cmd)
+ return a, tea.Batch(cmds...)
+}
- // Start the summarization process
- return a, func() tea.Msg {
- ctx := context.Background()
- a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
- return nil
- }
+func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+ var cmds []tea.Cmd
+ msg.Height -= 1 // Make space for the status bar
+ a.width, a.height = msg.Width, msg.Height
- case pubsub.Event[agent.AgentEvent]:
- payload := msg.Payload
- if payload.Error != nil {
- a.isCompacting = false
- return a, util.ReportError(payload.Error)
- }
+ // Update status bar
+ s, cmd := a.status.Update(msg)
+ a.status = s.(core.StatusCmp)
+ cmds = append(cmds, cmd)
- 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:
- updated, cmd := a.pages[a.currentPage].Update(msg)
- a.pages[a.currentPage] = updated.(util.Model)
- a.showThemeDialog = false
- t := theme.CurrentTheme()
- return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName), tea.SetBackgroundColor(t.Background()))
-
- 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)
- }
+ // Update the current page
+ updated, cmd := a.pages[a.currentPage].Update(msg)
+ a.pages[a.currentPage] = updated.(util.Model)
+ cmds = append(cmds, cmd)
- 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)
- }
- }
- } else {
- // Mark the project as initialized without running the command
- if err := config.MarkProjectInitialized(); err != nil {
- return a, util.ReportError(err)
- }
- }
- return a, nil
+ // Update the dialogs
+ dialog, cmd := a.dialog.Update(msg)
+ a.dialog = dialog.(dialogs.DialogCmp)
+ cmds = append(cmds, cmd)
- case chat.SessionSelectedMsg:
- a.selectedSession = msg
- a.sessionDialog.SetSelectedSession(msg.ID)
+ return tea.Batch(cmds...)
+}
- 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
+func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+ switch {
+ // dialogs
+ case key.Matches(msg, a.keyMap.Quit):
+ return util.CmdHandler(dialogs.OpenDialogMsg{
+ Model: quit.NewQuitDialog(),
+ })
- 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, nil
+ case key.Matches(msg, a.keyMap.Commands):
+ return util.CmdHandler(dialogs.OpenDialogMsg{
+ Model: commands.NewCommandDialog(),
+ })
- case tea.KeyPressMsg:
- // 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
- }
+ // Page navigation
+ case key.Matches(msg, a.keyMap.Logs):
+ return a.moveToPage(page.LogsPage)
- switch {
- 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)
- }
-
- 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.KeyPressMsg); ok {
- return a, tea.Batch(cmds...)
- }
- }
-
- 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.KeyPressMsg); 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.KeyPressMsg); ok {
- return a, tea.Batch(cmds...)
- }
- }
-
- 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.KeyPressMsg); ok {
- return a, 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.KeyPressMsg); ok {
- return a, tea.Batch(cmds...)
- }
- }
-
- 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.KeyPressMsg); ok {
- return a, tea.Batch(cmds...)
- }
- }
-
- 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.KeyPressMsg); ok {
- return a, tea.Batch(cmds...)
- }
- }
-
- 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.KeyPressMsg); ok {
- return a, tea.Batch(cmds...)
+ 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
}
}
-
- s, _ := a.status.Update(msg)
- a.status = s.(core.StatusCmp)
- updated, cmd := a.pages[a.currentPage].Update(msg)
- a.pages[a.currentPage] = updated.(util.Model)
- 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)
-}
+// func (a *appModel) RegisterCommand(cmd dialog.Command) {
+// a.commands = append(a.commands, cmd)
+// }
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
if a.app.CoderAgent.IsBusy() {
@@ -694,265 +653,276 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
return tea.Batch(cmds...)
}
-func (a appModel) View() string {
+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,
- )
- }
-
- 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.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,
- )
- }
-
- 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.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,
- )
- }
-
- 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.showPermissions {
+ // overlay := a.permissions.View().String()
+ // 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().String()
+ // 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(a.keymap)
+ // 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().String()
+ // 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.showSessionDialog {
+ // overlay := a.sessionDialog.View().String()
+ // 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.showModelDialog {
+ // overlay := a.modelDialog.View().String()
+ // 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.showCommandDialog {
+ // overlay := a.commandDialog.View().String()
+ // 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,
+ // )
+ // }
+ //
+ // if a.showThemeDialog {
+ // overlay := a.themeDialog.View().String()
+ // 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,
+ // )
+ // }
+ t := theme.CurrentTheme()
+ if a.dialog.HasDialogs() {
+ layers := append(
+ []*lipgloss.Layer{
+ lipgloss.NewLayer(appView),
+ },
+ a.dialog.GetLayers()...,
)
- }
-
- 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,
+ canvas := lipgloss.NewCanvas(
+ layers...,
)
+ view := tea.NewView(canvas.Render())
+ activeView := a.dialog.ActiveView()
+ view.SetBackgroundColor(t.Background())
+ view.SetCursor(activeView.Cursor())
+ return view
}
- return appView
+ view := tea.NewView(appView)
+ view.SetCursor(pageView.Cursor())
+ view.SetBackgroundColor(t.Background())
+ return view
}
func New(app *app.App) tea.Model {
startPage := page.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{},
+ currentPage: startPage,
+ app: app,
+ status: core.NewStatusCmp(app.LSPClients),
+ loadedPages: make(map[page.PageID]bool),
+ keyMap: DefaultKeyMap(),
+
+ // help: dialog.NewHelpCmp(),
+ // sessionDialog: dialog.NewSessionDialogCmp(),
+ // commandDialog: dialog.NewCommandDialogCmp(),
+ // modelDialog: dialog.NewModelDialogCmp(),
+ // permissions: dialog.NewPermissionDialogCmp(),
+ // initDialog: dialog.NewInitDialogCmp(),
+ // themeDialog: dialog.NewThemeDialogCmp(),
+ // commands: []dialog.Command{},
pages: map[page.PageID]util.Model{
page.ChatPage: page.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
},
- filepicker: dialog.NewFilepickerCmp(app),
- }
+ // 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,
- }),
- )
- },
- })
-
- 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)
- }
+ // New dialog
+ dialog: dialogs.NewDialogCmp(),
}
+ // 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,
+ // }),
+ // )
+ // },
+ // })
+ //
+ // 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)
+ // }
+ // }
+
return model
}
diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go
index ec658a0e65b649decf31fc4134183c2fa14925f7..8f7bb1bed15c184121cf5c0b16d9ba0cd98eb531 100644
--- a/internal/tui/util/util.go
+++ b/internal/tui/util/util.go
@@ -8,7 +8,7 @@ import (
type Model interface {
tea.Model
- tea.ViewModel
+ tea.Viewable
}
func CmdHandler(msg tea.Msg) tea.Cmd {
From b787dc0dfd2d9a0b5b9d9669b1fc215bb546eb5a Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Wed, 28 May 2025 17:32:21 +0200
Subject: [PATCH 22/73] wip command dialog
---
.opencode.json | 3 +
cspell.json | 2 +-
go.mod | 2 +
go.sum | 2 +
internal/tui/components/chat/list.go | 2 +-
internal/tui/components/core/list/keys.go | 6 +-
internal/tui/components/core/list/list.go | 291 +++++++++++++-----
.../components/dialogs/commands/commands.go | 101 +++---
.../tui/components/dialogs/commands/item.go | 103 ++++++-
internal/tui/tui.go | 3 -
10 files changed, 391 insertions(+), 124 deletions(-)
diff --git a/.opencode.json b/.opencode.json
index c4d1547a0c62aad24a470af1d503c225a5b5955b..75e357de711e3a49ea37519f9cd91f21bba8a25f 100644
--- a/.opencode.json
+++ b/.opencode.json
@@ -4,5 +4,8 @@
"gopls": {
"command": "gopls"
}
+ },
+ "tui": {
+ "theme": "opencode-dark"
}
}
diff --git a/cspell.json b/cspell.json
index b7dbd552ca81fc12eeb287709e68c150a3b8f6f7..9881e74f5d62a4b87631a2fd1ce372e2ebee804c 100644
--- a/cspell.json
+++ b/cspell.json
@@ -1 +1 @@
-{"flagWords":[],"version":"0.2","words":["opencode","charmbracelet","lipgloss","bubbletea"],"language":"en"}
\ No newline at end of file
+{"flagWords":[],"language":"en","words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable"],"version":"0.2"}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 30cf44c417849d7902bc7092a47f5bd925759fc5..52ab603e5f4a0158e0ac2dec3ddfc1cf5f8214ca 100644
--- a/go.mod
+++ b/go.mod
@@ -32,6 +32,8 @@ require (
github.com/stretchr/testify v1.10.0
)
+require github.com/sahilm/fuzzy v0.1.1
+
require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
diff --git a/go.sum b/go.sum
index 2cf341113db9f55d2127cbdba61c679cc4bdfe8d..eb7738075c88558f623578bd0bcfae89480bb1e8 100644
--- a/go.sum
+++ b/go.sum
@@ -199,6 +199,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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=
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
index 732e1d51d231720a3af4bd505d799c8ff6e23ea7..6fe7b96663bf29d495ac5806f5ffc049c1f1a4bd 100644
--- a/internal/tui/components/chat/list.go
+++ b/internal/tui/components/chat/list.go
@@ -45,7 +45,7 @@ type messageListCmp struct {
// 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()
+ defaultKeymaps := list.DefaultKeyMap()
defaultKeymaps.Up.SetEnabled(false)
defaultKeymaps.Down.SetEnabled(false)
defaultKeymaps.NDown = key.NewBinding(
diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go
index 4e534fed54d8112649bd785f112b29ce796ec394..23035c4030542b6a157a3dd08448ea4271d095d6 100644
--- a/internal/tui/components/core/list/keys.go
+++ b/internal/tui/components/core/list/keys.go
@@ -18,7 +18,7 @@ type KeyMap struct {
End key.Binding
}
-func DefaultKeymap() KeyMap {
+func DefaultKeyMap() KeyMap {
return KeyMap{
Down: key.NewBinding(
key.WithKeys("down", "ctrl+j", "ctrl+n"),
@@ -45,10 +45,10 @@ func DefaultKeymap() KeyMap {
key.WithKeys("ctrl+u"),
),
Home: key.NewBinding(
- key.WithKeys("g", "home"),
+ key.WithKeys("ctrl+g", "home"),
),
End: key.NewBinding(
- key.WithKeys("shift+g", "end"),
+ key.WithKeys("ctrl+shift+g", "end"),
),
}
}
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 1dd00a01b30a890dd123a670e8ceb4cf4277a3fd..235e9ee92d50fc071379464f3a2bfb3b437af13d 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -2,16 +2,21 @@ 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/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/components/anim"
"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"
+ "github.com/sahilm/fuzzy"
)
// Constants for special index values and defaults
@@ -43,6 +48,18 @@ type HasAnim interface {
Spinning() bool // Returns true if the item is currently animating
}
+// HasFilterValue interface allows items to provide a filter value for searching.
+type HasFilterValue interface {
+ util.Model
+ FilterValue() string // Returns a string value used for filtering/searching
+}
+
+// HasMatchIndexes interface allows items to set matched character indexes.
+type HasMatchIndexes interface {
+ util.Model
+ MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
+}
+
// renderedItem represents a cached rendered item with its position and content.
type renderedItem struct {
lines []string // The rendered lines of text for this item
@@ -105,10 +122,15 @@ type model struct {
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
- items []util.Model // The actual list items
+ 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
+
+ filterable bool // Whether items can be filtered
+ filteredItems []util.Model // Filtered items based on current search
+ input textinput.Model // Input field for filtering items
+ currentSearch string // Current search term for filtering
}
// listOptions is a function type for configuring list options.
@@ -117,7 +139,7 @@ type listOptions func(*model)
// WithKeyMap sets custom key bindings for the list.
func WithKeyMap(k KeyMap) listOptions {
return func(m *model) {
- m.keymap = k
+ m.keyMap = k
}
}
@@ -147,7 +169,15 @@ func WithPadding(padding ...int) listOptions {
// WithItems sets the initial items for the list.
func WithItems(items []util.Model) listOptions {
return func(m *model) {
- m.items = items
+ 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
}
}
@@ -157,8 +187,9 @@ func WithItems(items []util.Model) listOptions {
func New(opts ...listOptions) ListModel {
m := &model{
help: help.New(),
- keymap: DefaultKeymap(),
- items: []util.Model{},
+ keyMap: DefaultKeyMap(),
+ allItems: []util.Model{},
+ filteredItems: []util.Model{},
renderState: newRenderState(),
gapSize: DefaultGapSize,
padding: []int{},
@@ -167,13 +198,25 @@ func New(opts ...listOptions) ListModel {
for _, opt := range opts {
opt(m)
}
+
+ if m.filterable {
+ ti := textinput.New()
+ ti.Placeholder = "Type to filter..."
+ ti.SetVirtualCursor(false)
+ ti.Focus()
+ 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.items)
+ return m.SetItems(m.filteredItems)
}
// Update handles incoming messages and updates the list state accordingly.
@@ -186,34 +229,78 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
return m.handleAnimationMsg(msg)
}
-
- if m.selectionState.isValidIndex(len(m.items)) {
+ 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 {
+ content = lipgloss.JoinVertical(
+ lipgloss.Left,
+ m.inputStyle().Render(m.input.View()),
+ content,
+ )
+ }
+ view := tea.NewView(content)
+ if m.filterable {
+ 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):
+ 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):
+ case key.Matches(msg, m.keyMap.Up) || key.Matches(msg, m.keyMap.NUp):
m.scrollUp(1)
- case key.Matches(msg, m.keymap.DownOneItem):
+ case key.Matches(msg, m.keyMap.DownOneItem):
return m, m.selectNextItem()
- case key.Matches(msg, m.keymap.UpOneItem):
+ case key.Matches(msg, m.keyMap.UpOneItem):
return m, m.selectPreviousItem()
- case key.Matches(msg, m.keymap.HalfPageDown):
+ case key.Matches(msg, m.keyMap.HalfPageDown):
m.scrollDown(m.listHeight() / 2)
- case key.Matches(msg, m.keymap.HalfPageUp):
+ case key.Matches(msg, m.keyMap.HalfPageUp):
m.scrollUp(m.listHeight() / 2)
- case key.Matches(msg, m.keymap.Home):
+ case key.Matches(msg, m.keyMap.Home):
return m, m.goToTop()
- case key.Matches(msg, m.keymap.End):
+ case key.Matches(msg, m.keyMap.End):
return m, m.goToBottom()
+ default:
+ if !m.filterable {
+ return m, nil // Ignore other keys if not filterable
+ }
+ 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
}
@@ -222,7 +309,7 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
// 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.items {
+ for inx, item := range m.filteredItems {
if i, ok := item.(HasAnim); ok && i.Spinning() {
updated, cmd := i.Update(msg)
cmds = append(cmds, cmd)
@@ -238,7 +325,7 @@ func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
// 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.items[m.selectionState.selectedIndex].Update(msg)
+ 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)
@@ -266,27 +353,9 @@ func (m *model) scrollUp(amount int) {
}
}
-// 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()
- }
- return tea.NewView(
- lipgloss.NewStyle().
- Padding(m.padding...).
- Height(m.viewState.height).
- Render(m.viewState.content),
- )
-}
-
// Items returns a copy of all items in the list.
func (m *model) Items() []util.Model {
- return m.items
+ return m.filteredItems
}
// renderVisible determines which rendering strategy to use and triggers rendering.
@@ -306,12 +375,12 @@ func (m *model) renderVisibleForward() {
model: m,
start: 0,
cutoff: m.viewState.offset + m.listHeight(),
- items: m.items,
+ items: m.filteredItems,
realIdx: m.renderState.lastIndex,
}
if m.renderState.lastIndex > NotRendered {
- renderer.items = m.items[m.renderState.lastIndex+1:]
+ renderer.items = m.filteredItems[m.renderState.lastIndex+1:]
renderer.start = len(m.renderState.lines)
}
@@ -326,16 +395,16 @@ func (m *model) renderVisibleReverse() {
model: m,
start: 0,
cutoff: m.viewState.offset + m.listHeight(),
- items: m.items,
+ items: m.filteredItems,
realIdx: m.renderState.lastIndex,
}
if m.renderState.lastIndex > NotRendered {
- renderer.items = m.items[:m.renderState.lastIndex]
+ renderer.items = m.filteredItems[:m.renderState.lastIndex]
renderer.start = len(m.renderState.lines)
} else {
- m.renderState.lastIndex = len(m.items)
- renderer.realIdx = len(m.items)
+ m.renderState.lastIndex = len(m.filteredItems)
+ renderer.realIdx = len(m.filteredItems)
}
renderer.render()
@@ -389,7 +458,7 @@ func (r *forwardRenderer) render() {
}
itemLines := r.getOrRenderItem(item)
- if r.realIdx == len(r.model.items)-1 {
+ if r.realIdx == len(r.model.filteredItems)-1 {
r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
}
@@ -485,7 +554,7 @@ func (m *model) selectPreviousItem() tea.Cmd {
// selectNextItem moves selection to the next item in the list.
// Handles focus management and ensures the selected item remains visible.
func (m *model) selectNextItem() tea.Cmd {
- if m.selectionState.selectedIndex >= len(m.items)-1 || m.selectionState.selectedIndex < 0 {
+ if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
return nil
}
@@ -543,7 +612,7 @@ func (m *model) ensureVisibleForward(cachedItem renderedItem) {
// 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.items)-1 {
+ if m.selectionState.selectedIndex < len(m.filteredItems)-1 {
changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
m.decreaseOffset(changeNeeded)
} else {
@@ -567,7 +636,7 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
func (m *model) goToBottom() tea.Cmd {
cmds := []tea.Cmd{m.blurSelected()}
m.viewState.reverse = true
- m.selectionState.selectedIndex = len(m.items) - 1
+ m.selectionState.selectedIndex = len(m.filteredItems) - 1
cmds = append(cmds, m.focusSelected())
m.ResetView()
return tea.Batch(cmds...)
@@ -578,7 +647,7 @@ func (m *model) goToBottom() tea.Cmd {
func (m *model) goToTop() tea.Cmd {
cmds := []tea.Cmd{m.blurSelected()}
m.viewState.reverse = false
- if len(m.items) > 0 {
+ if len(m.filteredItems) > 0 {
m.selectionState.selectedIndex = 0
}
cmds = append(cmds, m.focusSelected())
@@ -596,10 +665,10 @@ func (m *model) ResetView() {
// 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.selectionState.isValidIndex(len(m.items)) {
+ if !m.selectionState.isValidIndex(len(m.filteredItems)) {
return nil
}
- if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
cmd := i.Focus()
m.rerenderItem(m.selectionState.selectedIndex)
return cmd
@@ -610,10 +679,10 @@ func (m *model) focusSelected() tea.Cmd {
// 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.items)) {
+ if !m.selectionState.isValidIndex(len(m.filteredItems)) {
return nil
}
- if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
cmd := i.Blur()
m.rerenderItem(m.selectionState.selectedIndex)
return cmd
@@ -625,7 +694,7 @@ func (m *model) blurSelected() tea.Cmd {
// 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.items) || len(m.renderState.lines) == 0 {
+ if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 {
return
}
@@ -634,7 +703,7 @@ func (m *model) rerenderItem(inx int) {
return
}
- rerenderedLines := m.getItemLines(m.items[inx])
+ rerenderedLines := m.getItemLines(m.filteredItems[inx])
if slices.Equal(cachedItem.lines, rerenderedLines) {
return
}
@@ -687,7 +756,7 @@ func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight
return
}
- if inx == len(m.items)-1 {
+ if inx == len(m.filteredItems)-1 {
m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
}
@@ -701,7 +770,7 @@ func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight
// 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.items); i++ {
+ for i := inx + 1; i < len(m.filteredItems); i++ {
if existing, ok := m.renderState.items[i]; ok {
existing.start = currentStart
currentStart += existing.height
@@ -766,12 +835,12 @@ func (m *model) decreaseOffset(n int) {
// 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.items) {
+ if inx < 0 || inx >= len(m.filteredItems) {
return
}
- m.items[inx] = item
+ m.filteredItems[inx] = item
if m.selectionState.selectedIndex == inx {
- if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
i.Focus()
}
}
@@ -788,6 +857,10 @@ func (m *model) GetSize() (int, int) {
// 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 {
+ height -= 2 // adjust for input field height and border
+ }
+
if m.viewState.width == width && m.viewState.height == height {
return nil
}
@@ -797,11 +870,14 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
}
m.viewState.width = width
m.ResetView()
+ if m.filterable {
+ m.input.SetWidth(m.getItemWidth() - 3)
+ }
return m.setAllItemsSize()
}
-// getItemSize calculates the available width for items, accounting for padding.
-func (m *model) getItemSize() int {
+// 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:
@@ -816,11 +892,11 @@ func (m *model) getItemSize() int {
// 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.items) {
+ if inx < 0 || inx >= len(m.filteredItems) {
return nil
}
- if i, ok := m.items[inx].(layout.Sizeable); ok {
- return i.SetSize(m.getItemSize(), 0)
+ if i, ok := m.filteredItems[inx].(layout.Sizeable); ok {
+ return i.SetSize(m.getItemWidth(), 0)
}
return nil
}
@@ -828,7 +904,7 @@ func (m *model) setItemSize(inx int) tea.Cmd {
// setAllItemsSize updates the size of all items that support sizing.
func (m *model) setAllItemsSize() tea.Cmd {
var cmds []tea.Cmd
- for i := range m.items {
+ for i := range m.filteredItems {
if cmd := m.setItemSize(i); cmd != nil {
cmds = append(cmds, cmd)
}
@@ -856,8 +932,9 @@ func (m *model) AppendItem(item util.Model) tea.Cmd {
cmds := []tea.Cmd{
item.Init(),
}
- m.items = append(m.items, item)
- cmds = append(cmds, m.setItemSize(len(m.items)-1))
+ 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...)
@@ -866,11 +943,12 @@ func (m *model) AppendItem(item util.Model) tea.Cmd {
// 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.items) {
+ if i < 0 || i >= len(m.filteredItems) {
return
}
- m.items = slices.Delete(m.items, i, i+1)
+ 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--
@@ -886,7 +964,8 @@ func (m *model) DeleteItem(i int) {
// 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.items = append([]util.Model{item}, m.items...)
+ 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))
@@ -917,16 +996,78 @@ func (m *model) setReverse(reverse bool) {
// SetItems replaces all items in the list with a new set.
// Initializes all items, sets their sizes, and establishes initial selection.
func (m *model) SetItems(items []util.Model) tea.Cmd {
- m.items = items
+ m.allItems = items
+ m.filteredItems = items
cmds := []tea.Cmd{m.setAllItemsSize()}
- for _, item := range m.items {
+ for _, item := range m.filteredItems {
cmds = append(cmds, item.Init())
}
- if len(m.items) > 0 {
+ if len(m.filteredItems) > 0 {
+ if m.viewState.reverse {
+ m.selectionState.selectedIndex = len(m.filteredItems) - 1
+ } else {
+ m.selectionState.selectedIndex = 0
+ }
+ if cmd := m.focusSelected(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ } else {
+ m.selectionState.selectedIndex = NoSelection
+ }
+
+ m.ResetView()
+ return tea.Batch(cmds...)
+}
+
+func (c *model) inputStyle() lipgloss.Style {
+ t := theme.CurrentTheme()
+ return styles.BaseStyle().
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(t.TextMuted()).
+ BorderBackground(t.Background()).
+ BorderBottom(true)
+}
+
+func (m *model) filter(search string) tea.Cmd {
+ var cmds []tea.Cmd
+ search = strings.TrimSpace(search)
+ search = strings.ToLower(search)
+ 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)) // Reset to all items if search is empty
+ return tea.Batch(cmds...)
+ }
+ words := make([]string, 0, len(m.allItems))
+ for _, cmd := range m.allItems {
+ if f, ok := cmd.(HasFilterValue); ok {
+ words = append(words, strings.ToLower(f.FilterValue()))
+ } else {
+ words = append(words, strings.ToLower(""))
+ }
+ }
+ matches := fuzzy.Find(search, words)
+ sort.Sort(matches)
+ filteredItems := make([]util.Model, 0, len(matches))
+ for _, match := range matches {
+ item := m.allItems[match.Index]
+ if i, ok := item.(HasMatchIndexes); ok {
+ i.MatchIndexes(match.MatchedIndexes)
+ }
+ filteredItems = append(filteredItems, item)
+ }
+ m.filteredItems = filteredItems
+ if len(filteredItems) > 0 {
if m.viewState.reverse {
- m.selectionState.selectedIndex = len(m.items) - 1
+ m.selectionState.selectedIndex = len(filteredItems) - 1
} else {
m.selectionState.selectedIndex = 0
}
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index 7a34fdd511bee2ade344ae44ca3652e0cecbe2c3..41b21064e13a938246fb733d0ff24a4bcdca3a40 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -1,11 +1,10 @@
package commands
import (
- "github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/logging"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/core/list"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/styles"
@@ -15,6 +14,8 @@ import (
const (
id dialogs.DialogID = "commands"
+
+ defaultWidth int = 60
)
// Command represents a command that can be executed
@@ -36,37 +37,31 @@ type commandDialogCmp struct {
wHeight int // Height of the terminal window
commandList list.ListModel
- input textinput.Model
- oldCursor tea.Cursor
}
func NewCommandDialog() CommandsDialog {
- ti := textinput.New()
- ti.Placeholder = "Type a command or search..."
- ti.SetVirtualCursor(false)
- ti.Focus()
- ti.SetWidth(60 - 7)
- commandList := list.New()
+ commandList := list.New(list.WithFilterable(true))
+
return &commandDialogCmp{
commandList: commandList,
- width: 60,
- input: ti,
+ width: defaultWidth,
}
}
func (c *commandDialogCmp) Init() tea.Cmd {
- logging.Info("Initializing commands dialog")
commands, err := LoadCustomCommands()
if err != nil {
return util.ReportError(err)
}
- logging.Info("Commands loaded", "count", len(commands))
+
+ commands = append(commands, c.defaultCommands()...)
commandItems := make([]util.Model, 0, len(commands))
for _, cmd := range commands {
commandItems = append(commandItems, NewCommandItem(cmd))
}
+
c.commandList.SetItems(commandItems)
return c.commandList.Init()
}
@@ -76,43 +71,40 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
c.wWidth = msg.Width
c.wHeight = msg.Height
- return c, c.commandList.SetSize(60, min(len(c.commandList.Items())*2, c.wHeight/2))
+ return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
}
- u, cmd := c.input.Update(msg)
- c.input = u
+ u, cmd := c.commandList.Update(msg)
+ c.commandList = u.(list.ListModel)
return c, cmd
}
func (c *commandDialogCmp) View() tea.View {
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- c.inputStyle().Render(c.input.View()),
- c.commandList.View().String(),
- )
-
- v := tea.NewView(c.style().Render(content))
- v.SetCursor(c.getCursor())
+ listView := c.commandList.View()
+ v := tea.NewView(c.style().Render(listView.String()))
+ if listView.Cursor() != nil {
+ c := c.moveCursor(listView.Cursor())
+ v.SetCursor(c)
+ }
return v
}
-func (c *commandDialogCmp) getCursor() *tea.Cursor {
- cursor := c.input.Cursor()
+func (c *commandDialogCmp) listWidth() int {
+ return defaultWidth - 4 // 4 for padding
+}
+
+func (c *commandDialogCmp) listHeight() int {
+ listHeigh := len(c.commandList.Items()) + 2 // height based on items + 2 for the input
+ return min(listHeigh, c.wHeight/2)
+}
+
+func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
offset := 10 + 1
cursor.Y += offset
_, col := c.Position()
- cursor.X = c.input.Cursor().X + col + 2
+ cursor.X = cursor.X + col + 2
return cursor
}
-func (c *commandDialogCmp) inputStyle() lipgloss.Style {
- t := theme.CurrentTheme()
- return styles.BaseStyle().
- BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(t.TextMuted()).
- BorderBackground(t.Background()).
- BorderBottom(true)
-}
-
func (c *commandDialogCmp) style() lipgloss.Style {
t := theme.CurrentTheme()
return styles.BaseStyle().
@@ -130,6 +122,41 @@ func (q *commandDialogCmp) Position() (int, int) {
return row, col
}
+func (c *commandDialogCmp) defaultCommands() []Command {
+ return []Command{
+ {
+ ID: "init",
+ Title: "Initialize Project",
+ Description: "Create/Update the OpenCode.md memory file",
+ Handler: func(cmd 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,
+ }),
+ )
+ },
+ },
+ {
+ 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 func() tea.Msg {
+ // TODO: implement compact message
+ return ""
+ }
+ },
+ },
+ }
+}
+
func (c *commandDialogCmp) ID() dialogs.DialogID {
return id
}
diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go
index 36c00199da323abc7038478079f353c1c2279820..5cdeae2112fd5d310587982b2a89ff82d7c2146b 100644
--- a/internal/tui/components/dialogs/commands/item.go
+++ b/internal/tui/components/dialogs/commands/item.go
@@ -2,23 +2,32 @@ package commands
import (
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
"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"
+ "github.com/rivo/uniseg"
)
type CommandItem interface {
util.Model
layout.Focusable
+ layout.Sizeable
}
type commandItem struct {
- command Command
- focus bool
+ width int
+ command Command
+ focus bool
+ matchIndexes []int
}
func NewCommandItem(command Command) CommandItem {
return &commandItem{
- command: command,
+ command: command,
+ matchIndexes: make([]int, 0),
}
}
@@ -34,7 +43,30 @@ func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
// View implements CommandItem.
func (c *commandItem) View() tea.View {
- return tea.NewView(c.command.Title)
+ t := theme.CurrentTheme()
+
+ baseStyle := styles.BaseStyle()
+ titleStyle := baseStyle.Width(c.width).Foreground(t.Text())
+ titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
+
+ if c.focus {
+ titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
+ titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
+ }
+ var ranges []lipgloss.Range
+ truncatedTitle := ansi.Truncate(c.command.Title, c.width-2, "…")
+ text := titleStyle.Padding(0, 1).Render(truncatedTitle)
+ if len(c.matchIndexes) > 0 {
+ 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(text, rng)
+ ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, titleMatchStyle))
+ }
+ text = lipgloss.StyleRanges(text, ranges...)
+ }
+ return tea.NewView(text)
}
// Blur implements CommandItem.
@@ -53,3 +85,66 @@ func (c *commandItem) Focus() tea.Cmd {
func (c *commandItem) IsFocused() bool {
return c.focus
}
+
+// GetSize implements CommandItem.
+func (c *commandItem) GetSize() (int, int) {
+ return c.width, 2
+}
+
+// SetSize implements CommandItem.
+func (c *commandItem) SetSize(width int, height int) tea.Cmd {
+ c.width = width
+ return nil
+}
+
+func (c *commandItem) FilterValue() string {
+ return c.command.Title
+}
+
+func (c *commandItem) MatchIndexes(indexes []int) {
+ c.matchIndexes = indexes
+}
+
+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/tui.go b/internal/tui/tui.go
index f3fffd9a383d4916698d8275885ed9a43e8b0665..e2dabdd777b464d16b84eeaf159e6ce5e685768a 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -103,9 +103,6 @@ func (a appModel) Init() tea.Cmd {
// }
// return dialog.ShowInitDialogMsg{Show: shouldShow}
// })
-
- t := theme.CurrentTheme()
- cmds = append(cmds, tea.SetBackgroundColor(t.Background()))
return tea.Batch(cmds...)
}
From 2329178f668057516c444975f5b56e45f37de628 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Wed, 28 May 2025 20:59:47 +0200
Subject: [PATCH 23/73] wip list sections
---
internal/tui/components/core/list/list.go | 251 +++++-
.../components/dialogs/commands/arguments.go | 72 ++
.../components/dialogs/commands/commands.go | 18 +-
.../tui/components/dialogs/commands/item.go | 59 +-
.../tui/components/dialogs/commands/keys.go | 1 +
.../tui/components/dialogs/commands/loader.go | 4 +-
internal/tui/components/dialogs/dialogs.go | 15 +-
internal/tui/components/dialogs/keys.go | 2 +-
internal/tui/tui.go | 712 +-----------------
9 files changed, 370 insertions(+), 764 deletions(-)
create mode 100644 internal/tui/components/dialogs/commands/keys.go
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 235e9ee92d50fc071379464f3a2bfb3b437af13d..e3da3bc36f78ab09197907644a3614f338a1e502 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -14,7 +14,6 @@ import (
"github.com/opencode-ai/opencode/internal/tui/components/anim"
"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"
"github.com/sahilm/fuzzy"
)
@@ -60,6 +59,13 @@ 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
@@ -539,6 +545,7 @@ func (r *reverseRenderer) renderItemLines(item util.Model) []string {
// 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 <= 0 {
return nil
@@ -546,6 +553,17 @@ func (m *model) selectPreviousItem() tea.Cmd {
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 {
+ m.selectionState.selectedIndex = m.findFirstSelectableItem()
+ }
+
cmds = append(cmds, m.focusSelected())
m.ensureSelectedItemVisible()
return tea.Batch(cmds...)
@@ -553,6 +571,7 @@ func (m *model) selectPreviousItem() tea.Cmd {
// 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 >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
return nil
@@ -560,11 +579,53 @@ func (m *model) selectNextItem() tea.Cmd {
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 := 0; i < len(m.filteredItems); i++ {
+ 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() {
@@ -631,25 +692,25 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
}
}
-// goToBottom switches to reverse mode and selects the last item.
+// 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
- m.selectionState.selectedIndex = len(m.filteredItems) - 1
+ 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 item.
+// 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
- if len(m.filteredItems) > 0 {
- m.selectionState.selectedIndex = 0
- }
+ m.selectionState.selectedIndex = m.findFirstSelectableItem()
cmds = append(cmds, m.focusSelected())
m.ResetView()
return tea.Batch(cmds...)
@@ -715,8 +776,12 @@ func (m *model) rerenderItem(inx int) {
}
// 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 {
- itemLines := strings.Split(item.View().String(), "\n")
+ var itemLines []string
+
+ itemLines = strings.Split(item.View().String(), "\n")
+
if m.gapSize > 0 {
gap := make([]string, m.gapSize)
itemLines = append(itemLines, gap...)
@@ -995,6 +1060,7 @@ func (m *model) setReverse(reverse bool) {
// 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
@@ -1006,9 +1072,9 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
if len(m.filteredItems) > 0 {
if m.viewState.reverse {
- m.selectionState.selectedIndex = len(m.filteredItems) - 1
+ m.selectionState.selectedIndex = m.findLastSelectableItem()
} else {
- m.selectionState.selectedIndex = 0
+ m.selectionState.selectedIndex = m.findFirstSelectableItem()
}
if cmd := m.focusSelected(); cmd != nil {
cmds = append(cmds, cmd)
@@ -1022,18 +1088,75 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
}
func (c *model) inputStyle() lipgloss.Style {
- t := theme.CurrentTheme()
- return styles.BaseStyle().
- BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(t.TextMuted()).
- BorderBackground(t.Background()).
- BorderBottom(true)
+ return styles.BaseStyle()
+}
+
+// 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())
@@ -1042,34 +1165,32 @@ func (m *model) filter(search string) tea.Cmd {
i.MatchIndexes(make([]int, 0))
}
}
+
if search == "" {
- cmds = append(cmds, m.SetItems(m.allItems)) // Reset to all items if search is empty
+ cmds = append(cmds, m.SetItems(m.allItems))
return tea.Batch(cmds...)
}
- words := make([]string, 0, len(m.allItems))
- for _, cmd := range m.allItems {
- if f, ok := cmd.(HasFilterValue); ok {
- words = append(words, strings.ToLower(f.FilterValue()))
- } else {
- words = append(words, strings.ToLower(""))
- }
- }
- matches := fuzzy.Find(search, words)
- sort.Sort(matches)
- filteredItems := make([]util.Model, 0, len(matches))
- for _, match := range matches {
- item := m.allItems[match.Index]
- if i, ok := item.(HasMatchIndexes); ok {
- i.MatchIndexes(match.MatchedIndexes)
+
+ // 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)
}
- filteredItems = append(filteredItems, item)
}
- m.filteredItems = filteredItems
- if len(filteredItems) > 0 {
+
+ // Rebuild flat list from filtered sections
+ m.filteredItems = m.flattenSections(filteredSections)
+
+ // Set initial selection
+ if len(m.filteredItems) > 0 {
if m.viewState.reverse {
- m.selectionState.selectedIndex = len(filteredItems) - 1
+ m.selectionState.selectedIndex = m.findLastSelectableItem()
} else {
- m.selectionState.selectedIndex = 0
+ m.selectionState.selectedIndex = m.findFirstSelectableItem()
}
if cmd := m.focusSelected(); cmd != nil {
cmds = append(cmds, cmd)
@@ -1081,3 +1202,59 @@ func (m *model) filter(search string) tea.Cmd {
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
+}
diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go
index 02ecf747c56aa93c8b65763d2931be2030e5975b..69e6a48dcc3b657d1587c62c1be5d3ce180c48c1 100644
--- a/internal/tui/components/dialogs/commands/arguments.go
+++ b/internal/tui/components/dialogs/commands/arguments.go
@@ -1,5 +1,17 @@
package commands
+import (
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/theme"
+)
+
+const (
+ argumentsDialogID dialogs.DialogID = "arguments"
+)
+
// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
type ShowArgumentsDialogMsg struct {
CommandID string
@@ -14,3 +26,63 @@ type CloseArgumentsDialogMsg struct {
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
+}
+
+func NewCommandArgumentsDialog() CommandArgumentsDialog {
+ return &commandArgumentsDialogCmp{}
+}
+
+// Init implements CommandArgumentsDialog.
+func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+// Update implements CommandArgumentsDialog.
+func (c *commandArgumentsDialogCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+ return c, nil
+}
+
+// View implements CommandArgumentsDialog.
+func (c *commandArgumentsDialogCmp) View() tea.View {
+ return tea.NewView("")
+}
+
+func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
+ offset := 10 + 1
+ cursor.Y += offset
+ _, col := c.Position()
+ cursor.X = cursor.X + col + 2
+ return cursor
+}
+
+func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
+ t := theme.CurrentTheme()
+ return styles.BaseStyle().
+ Width(c.width).
+ Padding(1).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(t.Background()).
+ BorderForeground(t.TextMuted())
+}
+
+func (q *commandArgumentsDialogCmp) Position() (int, int) {
+ row := 10
+ col := q.wWidth / 2
+ col -= q.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
index 41b21064e13a938246fb733d0ff24a4bcdca3a40..b52ad5c6bd6e653295e9acce33dc1013b05fd99e 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -13,7 +13,7 @@ import (
)
const (
- id dialogs.DialogID = "commands"
+ commandsDialogID dialogs.DialogID = "commands"
defaultWidth int = 60
)
@@ -54,11 +54,17 @@ func (c *commandDialogCmp) Init() tea.Cmd {
return util.ReportError(err)
}
- commands = append(commands, c.defaultCommands()...)
+ commandItems := []util.Model{}
+ if len(commands) > 0 {
+ commandItems = append(commandItems, NewItemSection("Custom"))
+ for _, cmd := range commands {
+ commandItems = append(commandItems, NewCommandItem(cmd))
+ }
+ }
- commandItems := make([]util.Model, 0, len(commands))
+ commandItems = append(commandItems, NewItemSection("Default"))
- for _, cmd := range commands {
+ for _, cmd := range c.defaultCommands() {
commandItems = append(commandItems, NewCommandItem(cmd))
}
@@ -93,7 +99,7 @@ func (c *commandDialogCmp) listWidth() int {
}
func (c *commandDialogCmp) listHeight() int {
- listHeigh := len(c.commandList.Items()) + 2 // height based on items + 2 for the input
+ 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)
}
@@ -158,5 +164,5 @@ func (c *commandDialogCmp) defaultCommands() []Command {
}
func (c *commandDialogCmp) ID() dialogs.DialogID {
- return id
+ return commandsDialogID
}
diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go
index 5cdeae2112fd5d310587982b2a89ff82d7c2146b..d1395af9c2889808d8a005d0940d017558795b13 100644
--- a/internal/tui/components/dialogs/commands/item.go
+++ b/internal/tui/components/dialogs/commands/item.go
@@ -1,9 +1,12 @@
package commands
import (
+ "strings"
+
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -54,15 +57,15 @@ func (c *commandItem) View() tea.View {
titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
}
var ranges []lipgloss.Range
- truncatedTitle := ansi.Truncate(c.command.Title, c.width-2, "…")
- text := titleStyle.Padding(0, 1).Render(truncatedTitle)
+ truncatedTitle := ansi.Truncate(c.command.Title, c.width, "…")
+ text := titleStyle.Render(truncatedTitle)
if len(c.matchIndexes) > 0 {
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(text, rng)
- ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, titleMatchStyle))
+ ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
}
text = lipgloss.StyleRanges(text, ranges...)
}
@@ -148,3 +151,53 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
stop = pos
return start, stop
}
+
+type ItemSection interface {
+ util.Model
+ layout.Sizeable
+ list.SectionHeader
+}
+type itemSectionModel struct {
+ width int
+ title string
+}
+
+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 := theme.CurrentTheme()
+ title := ansi.Truncate(m.title, m.width-1, "…")
+ style := styles.BaseStyle().Padding(1, 0, 0, 0).Width(m.width).Foreground(t.TextMuted()).Bold(true)
+ if len(title) < m.width {
+ remainingWidth := m.width - lipgloss.Width(title)
+ if remainingWidth > 0 {
+ title += " " + strings.Repeat("─", remainingWidth-1)
+ }
+ }
+ return tea.NewView(style.Render(title))
+}
+
+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..cdff10da75a9b02f8657b3b60631599137203efe
--- /dev/null
+++ b/internal/tui/components/dialogs/commands/keys.go
@@ -0,0 +1 @@
+package commands
diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go
index 8767d6bf7b0c3a3e901dcdebd029cc29d7da4ed6..92064394fa7b9f832dce7d9fd82b20a24e1127c2 100644
--- a/internal/tui/components/dialogs/commands/loader.go
+++ b/internal/tui/components/dialogs/commands/loader.go
@@ -162,7 +162,6 @@ func createCommandHandler(id string, content string) func(Command) tea.Cmd {
return util.CmdHandler(CommandRunCustomMsg{
Content: content,
- Args: nil,
})
}
}
@@ -189,7 +188,7 @@ func extractArgNames(content string) []string {
func ensureDir(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
- return os.MkdirAll(path, 0755)
+ return os.MkdirAll(path, 0o755)
}
return nil
}
@@ -200,5 +199,4 @@ func isMarkdownFile(name string) bool {
type CommandRunCustomMsg struct {
Content string
- Args map[string]string
}
diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go
index 9862388fc16af59b0dc3ac63a8485cc02924370d..f5e5e285de96ed7b59e0f6600ef9eb78548c22cd 100644
--- a/internal/tui/components/dialogs/dialogs.go
+++ b/internal/tui/components/dialogs/dialogs.go
@@ -23,11 +23,6 @@ type CloseCallback interface {
Close() tea.Cmd
}
-// AbsolutePositionable is an interface for components that can set their position
-type AbsolutePositionable interface {
- SetPosition(x, y int)
-}
-
// OpenDialogMsg is sent to open a new dialog with specified dimensions.
type OpenDialogMsg struct {
Model DialogModel
@@ -50,14 +45,14 @@ type dialogCmp struct {
width, height int
dialogs []DialogModel
idMap map[DialogID]int
- keymap KeyMap
+ keyMap KeyMap
}
// NewDialogCmp creates a new dialog manager.
func NewDialogCmp() DialogCmp {
return dialogCmp{
dialogs: []DialogModel{},
- keymap: DefaultKeymap(),
+ keyMap: DefaultKeyMap(),
idMap: make(map[DialogID]int),
}
}
@@ -94,7 +89,7 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return d, nil
case tea.KeyPressMsg:
- if key.Matches(msg, d.keymap.Close) {
+ if key.Matches(msg, d.keyMap.Close) {
return d, util.CmdHandler(CloseDialogMsg{})
}
}
@@ -114,10 +109,10 @@ func (d dialogCmp) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) {
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 ontop of quit
+ return d, nil // Do not open dialogs on top of quit
}
}
- // if the dialog is already in thel stack make it the last item
+ // 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
diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go
index 34a5aeb4d5b46b52e4ef6968e5c8bc480a2e3819..a3b68acb6e4d6b1773aa84933668f94bbc6a4e16 100644
--- a/internal/tui/components/dialogs/keys.go
+++ b/internal/tui/components/dialogs/keys.go
@@ -10,7 +10,7 @@ type KeyMap struct {
Close key.Binding
}
-func DefaultKeymap() KeyMap {
+func DefaultKeyMap() KeyMap {
return KeyMap{
Close: key.NewBinding(
key.WithKeys("esc"),
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index e2dabdd777b464d16b84eeaf159e6ce5e685768a..a176d895b69278252a4184fe879aca30b9cbe0ad 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -17,8 +17,6 @@ import (
"github.com/opencode-ai/opencode/internal/tui/util"
)
-// type startCompactSessionMsg struct{}
-
type appModel struct {
width, height int
keyMap KeyMap
@@ -32,40 +30,6 @@ type appModel struct {
app *app.App
- // selectedSession session.Session
- //
- // showPermissions bool
- // permissions dialog.PermissionDialogCmp
- //
- // showHelp bool
- // help dialog.HelpCmp
- //
- // showSessionDialog bool
- // sessionDialog dialog.SessionDialog
- //
- // showCommandDialog bool
- // commandDialog dialog.CommandDialog
- // commands []dialog.Command
- //
- // 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
-
- // NEW DIALOG
dialog dialogs.DialogCmp
}
@@ -77,32 +41,6 @@ func (a appModel) Init() tea.Cmd {
cmd = a.status.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 {
- // shouldShow, err := config.ShouldShowInitDialog()
- // if err != nil {
- // return util.InfoMsg{
- // Type: util.InfoTypeError,
- // Msg: "Failed to check init status: " + err.Error(),
- // }
- // }
- // return dialog.ShowInitDialogMsg{Show: shouldShow}
- // })
return tea.Batch(cmds...)
}
@@ -113,56 +51,13 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return a, a.handleWindowResize(msg)
- // TODO: remove when refactor is done
- // 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)
- // updated, cmd := a.pages[a.currentPage].Update(msg)
- // a.pages[a.currentPage] = updated.(util.Model)
- // 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)
- //
- // a.initDialog.SetSize(msg.Width, msg.Height)
- //
- // 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())
- // }
- //
- // dialog, cmd := a.dialog.Update(msg)
- // a.dialog = dialog.(dialogs.DialogCmp)
- // cmds = append(cmds, cmd)
- //
- // return a, tea.Batch(cmds...)
// Dialog messages
case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
u, dialogCmd := a.dialog.Update(msg)
a.dialog = u.(dialogs.DialogCmp)
return a, dialogCmd
+ case commands.ShowArgumentsDialogMsg:
// Page change messages
case page.PageChangeMsg:
@@ -170,398 +65,28 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Status Messages
case util.InfoMsg, util.ClearStatusMsg:
- s, cmd := a.status.Update(msg)
+ s, statusCmd := a.status.Update(msg)
a.status = s.(core.StatusCmp)
- cmds = append(cmds, cmd)
+ cmds = append(cmds, statusCmd)
return a, tea.Batch(cmds...)
+
// Logs
case pubsub.Event[logging.LogMessage]:
// Send to the status component
- s, cmd := a.status.Update(msg)
+ s, statusCmd := a.status.Update(msg)
a.status = s.(core.StatusCmp)
- cmds = append(cmds, cmd)
+ cmds = append(cmds, statusCmd)
// If the current page is logs, update the logs view
if a.currentPage == page.LogsPage {
- updated, cmd := a.pages[a.currentPage].Update(msg)
+ updated, pageCmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(util.Model)
- cmds = append(cmds, cmd)
+ cmds = append(cmds, pageCmd)
}
return a, tea.Batch(cmds...)
-
- // // Permission
- // case pubsub.Event[permission.PermissionRequest]:
- // a.showPermissions = true
- // return a, a.permissions.SetPermissions(msg.Payload)
- // case dialog.PermissionResponseMsg:
- // var cmd tea.Cmd
- // switch msg.Action {
- // case dialog.PermissionAllow:
- // a.app.Permissions.Grant(msg.Permission)
- // case dialog.PermissionAllowForSession:
- // a.app.Permissions.GrantPersistant(msg.Permission)
- // case dialog.PermissionDeny:
- // a.app.Permissions.Deny(msg.Permission)
- // }
- // a.showPermissions = false
- // return a, cmd
- //
- // // Theme changed
- // case dialog.ThemeChangedMsg:
- // updated, cmd := a.pages[a.currentPage].Update(msg)
- // a.pages[a.currentPage] = updated.(util.Model)
- // a.showThemeDialog = false
- // return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
- //
- // 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
- // }
- //
- // 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.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)
- // }
- //
- // 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)
- // }
- // }
- // } 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, nil
- //
case tea.KeyPressMsg:
return a, a.handleKeyPressMsg(msg)
- // if a.dialog.HasDialogs() {
- // u, dialogCmd := a.dialog.Update(msg)
- // a.dialog = u.(dialogs.DialogCmp)
- // return a, dialogCmd
- // }
- // // 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 {
- // case key.Matches(msg, keys.Quit):
- // // TODO: fix this after testing
- // // 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, util.CmdHandler(dialogs.OpenDialogMsg{
- // Model: quit.NewQuitDialog(),
- // })
- // case key.Matches(msg, keys.SwitchSession):
- // if a.currentPage == page.ChatPage && !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.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, util.CmdHandler(dialogs.OpenDialogMsg{
- // Model: commands.NewCommandDialog(),
- // })
- // case key.Matches(msg, keys.Models):
- // if a.showModelDialog {
- // a.showModelDialog = false
- // return a, nil
- // }
- // if a.currentPage == page.ChatPage && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
- // a.showModelDialog = true
- // return a, nil
- // }
- // return a, nil
- // case key.Matches(msg, keys.SwitchTheme):
- // if !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.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):
- // a.showHelp = !a.showHelp
- // return a, nil
- // case key.Matches(msg, helpEsc):
- // if a.app.CoderAgent.IsBusy() {
- // 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:
- // u, dialogCmd := a.dialog.Update(msg)
- // a.dialog = u.(dialogs.DialogCmp)
- // cmds = append(cmds, dialogCmd)
- // f, filepickerCmd := a.filepicker.Update(msg)
- // a.filepicker = f.(dialog.FilepickerCmp)
- // cmds = append(cmds, filepickerCmd)
- // }
-
- // 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.KeyPressMsg); 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.KeyPressMsg); ok {
- // return a, tea.Batch(cmds...)
- // }
- // }
- //
- // 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.KeyPressMsg); ok {
- // return a, 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.KeyPressMsg); ok {
- // return a, tea.Batch(cmds...)
- // }
- // }
- //
- // 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.KeyPressMsg); ok {
- // return a, tea.Batch(cmds...)
- // }
- // }
- //
- // 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.KeyPressMsg); ok {
- // return a, tea.Batch(cmds...)
- // }
- // }
- //
- // 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.KeyPressMsg); ok {
- // return a, tea.Batch(cmds...)
- // }
}
- //
s, _ := a.status.Update(msg)
a.status = s.(core.StatusCmp)
updated, cmd := a.pages[a.currentPage].Update(msg)
@@ -659,177 +184,6 @@ func (a *appModel) View() tea.View {
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
- // if a.showPermissions {
- // overlay := a.permissions.View().String()
- // 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().String()
- // 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(a.keymap)
- // 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().String()
- // 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.showSessionDialog {
- // overlay := a.sessionDialog.View().String()
- // 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.showModelDialog {
- // overlay := a.modelDialog.View().String()
- // 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.showCommandDialog {
- // overlay := a.commandDialog.View().String()
- // 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,
- // )
- // }
- //
- // if a.showThemeDialog {
- // overlay := a.themeDialog.View().String()
- // 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,
- // )
- // }
t := theme.CurrentTheme()
if a.dialog.HasDialogs() {
layers := append(
@@ -863,63 +217,13 @@ func New(app *app.App) tea.Model {
loadedPages: make(map[page.PageID]bool),
keyMap: DefaultKeyMap(),
- // help: dialog.NewHelpCmp(),
- // sessionDialog: dialog.NewSessionDialogCmp(),
- // commandDialog: dialog.NewCommandDialogCmp(),
- // modelDialog: dialog.NewModelDialogCmp(),
- // permissions: dialog.NewPermissionDialogCmp(),
- // initDialog: dialog.NewInitDialogCmp(),
- // themeDialog: dialog.NewThemeDialogCmp(),
- // commands: []dialog.Command{},
pages: map[page.PageID]util.Model{
page.ChatPage: page.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
},
- // filepicker: dialog.NewFilepickerCmp(app),
- // New dialog
dialog: dialogs.NewDialogCmp(),
}
- // 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,
- // }),
- // )
- // },
- // })
- //
- // 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)
- // }
- // }
-
return model
}
From e9a8bda49a97f905a3db47b2c941591da1511244 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Thu, 29 May 2025 18:39:59 +0200
Subject: [PATCH 24/73] wip arguments dialog
---
internal/tui/components/core/list/list.go | 9 +
.../components/dialogs/commands/arguments.go | 174 +++++++++++++++++-
.../components/dialogs/commands/commands.go | 46 ++++-
.../tui/components/dialogs/commands/item.go | 6 +
.../tui/components/dialogs/commands/keys.go | 92 +++++++++
internal/tui/tui.go | 10 +-
6 files changed, 325 insertions(+), 12 deletions(-)
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index e3da3bc36f78ab09197907644a3614f338a1e502..c1a678ab0a4af6fce0cd62f7c8972e7656c7e8ae 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -38,6 +38,7 @@ type ListModel interface {
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
}
// HasAnim interface identifies items that support animation.
@@ -1258,3 +1259,11 @@ func (m *model) filterSection(sect section, search string) *section {
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
+}
diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go
index 69e6a48dcc3b657d1587c62c1be5d3ce180c48c1..16963c22dc756483e18e616f1fa44dd425accd7d 100644
--- a/internal/tui/components/dialogs/commands/arguments.go
+++ b/internal/tui/components/dialogs/commands/arguments.go
@@ -1,11 +1,18 @@
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/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"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 (
@@ -36,10 +43,55 @@ 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() CommandArgumentsDialog {
- return &commandArgumentsDialogCmp{}
+func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog {
+ 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.SetWidth(40)
+ ti.SetVirtualCursor(false)
+ ti.Prompt = ""
+ ds := ti.Styles()
+
+ ds.Blurred.Placeholder = ds.Blurred.Placeholder.Background(t.Background()).Foreground(t.TextMuted())
+ ds.Blurred.Prompt = ds.Blurred.Prompt.Background(t.Background()).Foreground(t.TextMuted())
+ ds.Blurred.Text = ds.Blurred.Text.Background(t.Background()).Foreground(t.TextMuted())
+ ds.Focused.Placeholder = ds.Blurred.Placeholder.Background(t.Background()).Foreground(t.TextMuted())
+ ds.Focused.Prompt = ds.Blurred.Prompt.Background(t.Background()).Foreground(t.Text())
+ ds.Focused.Text = ds.Blurred.Text.Background(t.Background()).Foreground(t.Text())
+ ti.SetStyles(ds)
+ // 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.
@@ -48,20 +100,130 @@ func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
}
// Update implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+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 {
- return tea.NewView("")
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ title := lipgloss.NewStyle().
+ Foreground(t.Primary()).
+ Bold(true).
+ Padding(0, 1).
+ Background(t.Background()).
+ Render("Command Arguments")
+
+ explanation := lipgloss.NewStyle().
+ Foreground(t.Text()).
+ Padding(0, 1).
+ Background(t.Background()).
+ 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 := lipgloss.NewStyle().
+ Padding(1, 1, 0, 1).
+ Background(t.Background())
+
+ if i == c.focusIndex {
+ labelStyle = labelStyle.Foreground(t.Text()).Bold(true)
+ } else {
+ labelStyle = labelStyle.Foreground(t.TextMuted())
+ }
+
+ label := labelStyle.Render(c.argNames[i] + ":")
+
+ field := lipgloss.NewStyle().
+ Foreground(t.Text()).
+ Padding(0, 1).
+ Background(t.Background()).
+ 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()).
+ BorderBackground(t.Background()).
+ BorderForeground(t.TextMuted()).
+ Background(t.Background()).
+ 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 {
- offset := 10 + 1
+ offset := 13 + (1+c.focusIndex)*3
cursor.Y += offset
_, col := c.Position()
- cursor.X = cursor.X + col + 2
+ cursor.X = cursor.X + col + 3
return cursor
}
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index b52ad5c6bd6e653295e9acce33dc1013b05fd99e..55cfefd5af592854cb38161f0e7e546a6e71b295 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -1,6 +1,7 @@
package commands
import (
+ "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
@@ -37,14 +38,31 @@ type commandDialogCmp struct {
wHeight int // Height of the terminal window
commandList list.ListModel
+ commands []Command
+ keyMap CommandsDialogKeyMap
}
func NewCommandDialog() CommandsDialog {
- commandList := list.New(list.WithFilterable(true))
-
+ 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
+
+ commandList := list.New(list.WithFilterable(true), list.WithKeyMap(listKeyMap))
return &commandDialogCmp{
commandList: commandList,
width: defaultWidth,
+ keyMap: DefaultCommandsDialogKeyMap(),
}
}
@@ -53,6 +71,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
if err != nil {
return util.ReportError(err)
}
+ c.commands = commands
commandItems := []util.Model{}
if len(commands) > 0 {
@@ -65,6 +84,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
commandItems = append(commandItems, NewItemSection("Default"))
for _, cmd := range c.defaultCommands() {
+ c.commands = append(c.commands, cmd)
commandItems = append(commandItems, NewCommandItem(cmd))
}
@@ -78,10 +98,26 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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].(CommandItem).Command()
+ return c, tea.Sequence(
+ util.CmdHandler(dialogs.CloseDialogMsg{}),
+ selectedItem.Handler(selectedItem),
+ )
+ default:
+ u, cmd := c.commandList.Update(msg)
+ c.commandList = u.(list.ListModel)
+ return c, cmd
+ }
}
- u, cmd := c.commandList.Update(msg)
- c.commandList = u.(list.ListModel)
- return c, cmd
+ return c, nil
}
func (c *commandDialogCmp) View() tea.View {
diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go
index d1395af9c2889808d8a005d0940d017558795b13..e656c1c3a6133763f7bc4bc78c438b9c84f4c3b1 100644
--- a/internal/tui/components/dialogs/commands/item.go
+++ b/internal/tui/components/dialogs/commands/item.go
@@ -18,6 +18,7 @@ type CommandItem interface {
util.Model
layout.Focusable
layout.Sizeable
+ Command() Command
}
type commandItem struct {
@@ -72,6 +73,11 @@ func (c *commandItem) View() tea.View {
return tea.NewView(text)
}
+// Command implements CommandItem.
+func (c *commandItem) Command() Command {
+ return c.command
+}
+
// Blur implements CommandItem.
func (c *commandItem) Blur() tea.Cmd {
c.focus = false
diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go
index cdff10da75a9b02f8657b3b60631599137203efe..92c2695f5aff71e640aeb41f165237766644210d 100644
--- a/internal/tui/components/dialogs/commands/keys.go
+++ b/internal/tui/components/dialogs/commands/keys.go
@@ -1 +1,93 @@
package commands
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type CommandsDialogKeyMap struct {
+ Select key.Binding
+ Next key.Binding
+ Previous key.Binding
+}
+
+func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
+ return CommandsDialogKeyMap{
+ Select: 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 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.Select,
+ k.Next,
+ k.Previous,
+ }
+}
+
+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/tui.go b/internal/tui/tui.go
index a176d895b69278252a4184fe879aca30b9cbe0ad..f2b99e0711915c402583c05ca77142d3a047af6c 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -58,7 +58,15 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
From 93e39fc45e67a11dfe204d04f5396499bf7859c3 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Thu, 29 May 2025 21:02:59 -0400
Subject: [PATCH 25/73] feat: title block package
---
go.mod | 8 +-
go.sum | 4 +-
internal/tui/components/title/title.go | 380 +++++++++++++++++++++++++
3 files changed, 386 insertions(+), 6 deletions(-)
create mode 100644 internal/tui/components/title/title.go
diff --git a/go.mod b/go.mod
index 52ab603e5f4a0158e0ac2dec3ddfc1cf5f8214ca..87928e392256bead51a38f3773fc6728cb2717b3 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ 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
@@ -26,14 +27,13 @@ require (
github.com/ncruces/go-sqlite3 v0.25.0
github.com/openai/openai-go v0.1.0-beta.2
github.com/pressly/goose/v3 v3.24.2
+ 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/stretchr/testify v1.10.0
)
-require github.com/sahilm/fuzzy v0.1.1
-
require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
@@ -61,7 +61,7 @@ require (
github.com/aymerick/douceur v0.2.0 // 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-20250327172914-2fdc97757edf // 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
@@ -92,7 +92,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
diff --git a/go.sum b/go.sum
index eb7738075c88558f623578bd0bcfae89480bb1e8..7acf6b4bada37cbe5776a3c84ee3ff0ddc7e1f3a 100644
--- a/go.sum
+++ b/go.sum
@@ -84,8 +84,8 @@ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
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-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
+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=
diff --git a/internal/tui/components/title/title.go b/internal/tui/components/title/title.go
new file mode 100644
index 0000000000000000000000000000000000000000..3c97ebab31b74d5a8b291eae9acb5d6eeca5315b
--- /dev/null
+++ b/internal/tui/components/title/title.go
@@ -0,0 +1,380 @@
+package title
+
+import (
+ "fmt"
+ "image/color"
+ "math/rand/v2"
+ "strings"
+
+ "github.com/MakeNowJust/heredoc"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/exp/slice"
+ "github.com/lucasb-eyer/go-colorful"
+ "github.com/rivo/uniseg"
+)
+
+// 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 title art. Set the argument to true to render the
+// narrow version, intended for use in a sidebar.
+//
+// The compact argument determins 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, applyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+ }
+ crush = b.String()
+
+ // Charm and version.
+ 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...)
+}
+
+// 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, lipgloss.NewStyle().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 {
+ t := float64(j) / float64(segmentSize)
+ c := c1.BlendHcl(c2, t)
+ blended = append(blended, c)
+ }
+ }
+
+ return blended
+}
From a74f6526adf81123ea23bddf0d0721fc62b484f8 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Thu, 29 May 2025 23:50:18 -0400
Subject: [PATCH 26/73] chore: truncate version number if too long
---
internal/tui/components/title/title.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/internal/tui/components/title/title.go b/internal/tui/components/title/title.go
index 3c97ebab31b74d5a8b291eae9acb5d6eeca5315b..63965c78ae115c68a843ab44a4b5dea80715da40 100644
--- a/internal/tui/components/title/title.go
+++ b/internal/tui/components/title/title.go
@@ -8,6 +8,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/slice"
"github.com/lucasb-eyer/go-colorful"
"github.com/rivo/uniseg"
@@ -50,6 +51,9 @@ func Render(version string, compact bool, o Opts) string {
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)
From a86b4e26933387a0abb407286e90e5e69c002ec5 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Thu, 29 May 2025 23:51:24 -0400
Subject: [PATCH 27/73] chore: rename 'title' package to 'logo'
---
internal/tui/components/{title/title.go => logo/logo.go} | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
rename internal/tui/components/{title/title.go => logo/logo.go} (99%)
diff --git a/internal/tui/components/title/title.go b/internal/tui/components/logo/logo.go
similarity index 99%
rename from internal/tui/components/title/title.go
rename to internal/tui/components/logo/logo.go
index 63965c78ae115c68a843ab44a4b5dea80715da40..e187fb7bcc29bf49a44f6f60bdba4220ae5ad578 100644
--- a/internal/tui/components/title/title.go
+++ b/internal/tui/components/logo/logo.go
@@ -1,4 +1,4 @@
-package title
+package logo
import (
"fmt"
From 47bb36b49d2001c9c08f8419921a3349ffee39d7 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Thu, 29 May 2025 23:51:48 -0400
Subject: [PATCH 28/73] chore: fix typo in comment
---
internal/tui/components/logo/logo.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go
index e187fb7bcc29bf49a44f6f60bdba4220ae5ad578..ab9e300c55da50f2942afe14a1e5514e042651a6 100644
--- a/internal/tui/components/logo/logo.go
+++ b/internal/tui/components/logo/logo.go
@@ -29,10 +29,10 @@ type Opts struct {
VersionColor color.Color // Version text color
}
-// Render renders the Crush title art. Set the argument to true to render the
-// narrow version, intended for use in a sidebar.
+// Render renders the Crush logo. Set the argument to true to render the narrow
+// version, intended for use in a sidebar.
//
-// The compact argument determins whether it renders compact for the 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™"
From 8807bfebcc8254d518b5aa1eebb541b8024e9720 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Fri, 30 May 2025 00:00:19 -0400
Subject: [PATCH 29/73] feat: integrate new title block
---
internal/tui/components/chat/chat.go | 29 ++++++++++------------------
internal/tui/styles/icons.go | 2 --
2 files changed, 10 insertions(+), 21 deletions(-)
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index d261902102ddcf20edf9d735ea6b7808195a163d..2ee0b042315c5608ccb1d4aacf5e25f531c20a92 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -8,6 +8,7 @@ import (
"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/components/logo"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/version"
@@ -27,7 +28,7 @@ type EditorFocusMsg bool
func header() string {
return lipgloss.JoinVertical(
lipgloss.Top,
- logo(),
+ logoBlock(),
repo(),
"",
cwd(),
@@ -91,25 +92,15 @@ func lspsConfigured() string {
)
}
-func logo() string {
- logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
+func logoBlock() string {
t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- versionText := baseStyle.
- Foreground(t.TextMuted()).
- Render(version.Version)
-
- return baseStyle.
- Bold(true).
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- logo,
- " ",
- versionText,
- ),
- )
+ return logo.Render(version.Version, true, logo.Opts{
+ FieldColor: t.Accent(),
+ TitleColorA: t.Primary(),
+ TitleColorB: t.Secondary(),
+ CharmColor: t.Primary(),
+ VersionColor: t.Secondary(),
+ })
}
func repo() string {
diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go
index 87255ccd2801f662a2282b3dad237c36464f0781..59f43dca6d995255688268f7859bce774b49aad5 100644
--- a/internal/tui/styles/icons.go
+++ b/internal/tui/styles/icons.go
@@ -1,8 +1,6 @@
package styles
const (
- OpenCodeIcon string = "⌬"
-
CheckIcon string = "✓"
ErrorIcon string = "✖"
WarningIcon string = "⚠"
From ff1161c85cc86d3b883ab9c4ceaca43d24654616 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Fri, 30 May 2025 00:04:26 -0400
Subject: [PATCH 30/73] fix(lint): correct typo
---
internal/permission/permission.go | 4 ++--
internal/tui/components/core/status.go | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/internal/permission/permission.go b/internal/permission/permission.go
index d6fdea664465bb3c5318e320e68f992a27e58a13..3532f5be685608f2dbb0e992924b4606f2db96d8 100644
--- a/internal/permission/permission.go
+++ b/internal/permission/permission.go
@@ -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/tui/components/core/status.go b/internal/tui/components/core/status.go
index 9d01d835c1a581aa52de241b4598efc8e3171d09..648db2a23fa7b5930b5a5b2dadd9c8c398ca202e 100644
--- a/internal/tui/components/core/status.go
+++ b/internal/tui/components/core/status.go
@@ -72,7 +72,7 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case util.ClearStatusMsg:
m.info = util.InfoMsg{}
- // Handle persistant logs
+ // Handle persistent logs
case pubsub.Event[logging.LogMessage]:
if msg.Payload.Persist {
switch msg.Payload.Level {
From 6f121438a855cedf6d43d90e01e15c880d87f76a Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Fri, 30 May 2025 00:09:35 -0400
Subject: [PATCH 31/73] chore: align 'Charm' to edge of C curve
---
internal/tui/components/logo/logo.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go
index ab9e300c55da50f2942afe14a1e5514e042651a6..15b5f97e66fb0144cdf0bf65db6604270e2c196c 100644
--- a/internal/tui/components/logo/logo.go
+++ b/internal/tui/components/logo/logo.go
@@ -35,7 +35,7 @@ type Opts struct {
// 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™"
+ const charm = " Charm™"
fg := func(c color.Color, s string) string {
return lipgloss.NewStyle().Foreground(c).Render(s)
From b37ff5fec5a67699465969a3c034210da1d068a7 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Fri, 30 May 2025 21:54:16 +0200
Subject: [PATCH 32/73] implement completions
---
go.mod | 3 +-
go.sum | 8 +-
internal/completions/files-folders.go | 191 --------
internal/fileutil/fileutil.go | 101 ++--
internal/fileutil/ls.go | 169 +++++++
internal/llm/tools/glob.go | 2 +-
internal/llm/tools/ls.go | 98 +---
internal/llm/tools/ls_test.go | 457 ------------------
.../components/chat/{ => editor}/editor.go | 142 ++++--
internal/tui/components/chat/editor/keys.go | 59 +++
.../tui/components/completions/completions.go | 195 ++++++++
internal/tui/components/completions/item.go | 247 ++++++++++
internal/tui/components/completions/keys.go | 53 ++
internal/tui/components/core/list/list.go | 38 +-
internal/tui/components/dialog/arguments.go | 252 ----------
internal/tui/components/dialog/commands.go | 182 -------
internal/tui/components/dialog/complete.go | 264 ----------
.../tui/components/dialog/custom_commands.go | 185 -------
.../components/dialog/custom_commands_test.go | 106 ----
.../components/dialogs/commands/commands.go | 9 +-
.../tui/components/dialogs/commands/item.go | 145 ------
internal/tui/page/chat.go | 102 +---
internal/tui/tui.go | 69 ++-
23 files changed, 1009 insertions(+), 2068 deletions(-)
delete mode 100644 internal/completions/files-folders.go
create mode 100644 internal/fileutil/ls.go
delete mode 100644 internal/llm/tools/ls_test.go
rename internal/tui/components/chat/{ => editor}/editor.go (69%)
create mode 100644 internal/tui/components/chat/editor/keys.go
create mode 100644 internal/tui/components/completions/completions.go
create mode 100644 internal/tui/components/completions/item.go
create mode 100644 internal/tui/components/completions/keys.go
delete mode 100644 internal/tui/components/dialog/arguments.go
delete mode 100644 internal/tui/components/dialog/commands.go
delete mode 100644 internal/tui/components/dialog/complete.go
delete mode 100644 internal/tui/components/dialog/custom_commands.go
delete mode 100644 internal/tui/components/dialog/custom_commands_test.go
diff --git a/go.mod b/go.mod
index 87928e392256bead51a38f3773fc6728cb2717b3..0fb1b62102f0a7a3ed14652c28c1bf814a480fdf 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
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/charlievieth/fastwalk v1.0.11
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
@@ -27,6 +28,7 @@ require (
github.com/ncruces/go-sqlite3 v0.25.0
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
@@ -81,7 +83,6 @@ 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-runewidth v0.0.16 // indirect
diff --git a/go.sum b/go.sum
index 7acf6b4bada37cbe5776a3c84ee3ff0ddc7e1f3a..c60ac51e573f37283022305dce9e10f9c2f0ed5f 100644
--- a/go.sum
+++ b/go.sum
@@ -68,6 +68,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
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/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.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
@@ -148,8 +150,6 @@ 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/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=
@@ -197,6 +197,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
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=
@@ -224,6 +226,7 @@ github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj
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=
@@ -348,6 +351,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/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/fileutil/fileutil.go b/internal/fileutil/fileutil.go
index 1883f1853db8aa414e1ca0b392c7e7f858d7f068..125979df7b98247dcde89980671fddf851dfb2ef 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/charlievieth/fastwalk"
"github.com/opencode-ai/opencode/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
@@ -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 {
diff --git a/internal/fileutil/ls.go b/internal/fileutil/ls.go
new file mode 100644
index 0000000000000000000000000000000000000000..9ea0dfa670388f46ff339f77f03a9dd60897d2b8
--- /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",
+
+ // OpenCode
+ ".opencode",
+}
+
+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/llm/tools/glob.go b/internal/llm/tools/glob.go
index 9894d9baab1ef778865ea10c0ea04a67848ea6e8..5726c612ef8de79fbf05e227bdedb346b48e7add 100644
--- a/internal/llm/tools/glob.go
+++ b/internal/llm/tools/glob.go
@@ -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/ls.go b/internal/llm/tools/ls.go
index 0febbf8e8f28c3d64c97e755c2bdf8068131c355..383fc50507585382ec2611a03ac0d2c58f4e09b4 100644
--- a/internal/llm/tools/ls.go
+++ b/internal/llm/tools/ls.go
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/opencode-ai/opencode/internal/config"
+ "github.com/opencode-ai/opencode/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 98c97ed95b5db4bbb0ee5f21ba5ee646a43de889..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, 0o755)
- require.NoError(t, err)
- }
-
- // Create files
- for _, file := range testFiles {
- filePath := filepath.Join(tempDir, file)
- err := os.WriteFile(filePath, []byte("test content"), 0o644)
- 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, 0o755)
- require.NoError(t, err)
- }
-
- // Create files
- for _, file := range testFiles {
- filePath := filepath.Join(tempDir, file)
- err := os.WriteFile(filePath, []byte("test content"), 0o644)
- 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)
- })
-}
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor/editor.go
similarity index 69%
rename from internal/tui/components/chat/editor.go
rename to internal/tui/components/chat/editor/editor.go
index 430b0b4cf3f90cd399cc8fd7be73761e9cd77e92..c0f17d6f78fd579b42e1ca55acc1b7b4f7b00e8a 100644
--- a/internal/tui/components/chat/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -1,4 +1,4 @@
-package chat
+package editor
import (
"fmt"
@@ -13,9 +13,12 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
+ "github.com/opencode-ai/opencode/internal/fileutil"
"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/chat"
+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
"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"
@@ -23,6 +26,10 @@ import (
"github.com/opencode-ai/opencode/internal/tui/util"
)
+type FileCompletionItem struct {
+ Path string // The file path
+}
+
type editorCmp struct {
width int
height int
@@ -32,35 +39,21 @@ type editorCmp struct {
textarea textarea.Model
attachments []message.Attachment
deleteMode bool
-}
-type EditorKeyMaps struct {
- Send key.Binding
- OpenEditor key.Binding
-}
+ keyMap EditorKeyMap
-type bluredEditorKeyMaps struct {
- Send key.Binding
- Focus key.Binding
- OpenEditor key.Binding
+ // File path completions
+ currentQuery string
+ completionsStartIndex int
+ isCompletionsOpen bool
}
+
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"),
@@ -109,7 +102,7 @@ func (m *editorCmp) openEditor() tea.Cmd {
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
- return SendMsg{
+ return chat.SendMsg{
Text: string(content),
Attachments: attachments,
}
@@ -134,7 +127,7 @@ func (m *editorCmp) send() tea.Cmd {
return nil
}
return tea.Batch(
- util.CmdHandler(SendMsg{
+ util.CmdHandler(chat.SendMsg{
Text: value,
Attachments: attachments,
}),
@@ -143,16 +136,12 @@ func (m *editorCmp) send() tea.Cmd {
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
+ var cmds []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:
+ return m, cmd
+ case chat.SessionSelectedMsg:
if msg.ID != m.session.ID {
m.session = msg
}
@@ -163,7 +152,64 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@@ -186,7 +232,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}
- if key.Matches(msg, editorMaps.OpenEditor) {
+ if key.Matches(msg, m.keyMap.OpenEditor) {
if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
return m, util.ReportWarn("Agent is working, please wait...")
}
@@ -197,7 +243,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// Hanlde Enter key
- if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
+ 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
@@ -210,7 +256,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
m.textarea, cmd = m.textarea.Update(msg)
- return m, cmd
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
}
func (m *editorCmp) View() tea.View {
@@ -223,8 +270,8 @@ func (m *editorCmp) View() tea.View {
Foreground(t.Primary())
cursor := m.textarea.Cursor()
- cursor.X = m.textarea.Cursor().X + m.x + 2
- cursor.Y = m.textarea.Cursor().Y + m.y + 1
+ cursor.X = cursor.X + m.x + 2
+ cursor.Y = cursor.Y + m.y + 1
if len(m.attachments) == 0 {
view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
view.SetCursor(cursor)
@@ -278,7 +325,7 @@ func (m *editorCmp) attachmentsContent() string {
func (m *editorCmp) BindingKeys() []key.Binding {
bindings := []key.Binding{}
- bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
+ bindings = append(bindings, layout.KeyMapToSlice(m.keyMap)...)
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
return bindings
}
@@ -289,6 +336,28 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
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,
+ }
+}
+
func CreateTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.Background()
@@ -333,5 +402,6 @@ func NewEditorCmp(app *app.App) util.Model {
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..69bffd81c1ad1214be49d73bab2e36d019a87ba4
--- /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/opencode-ai/opencode/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/completions/completions.go b/internal/tui/components/completions/completions.go
new file mode 100644
index 0000000000000000000000000000000000000000..7733aac48ccc27c4b43a61880873009d77ff0a66
--- /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/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
+ "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 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{}
+ for _, completion := range msg.Completions {
+ item := NewCompletionItem(completion.Title, completion.Value)
+ 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 := theme.CurrentTheme()
+ return styles.BaseStyle().
+ Width(c.width).
+ Height(c.height).
+ Background(t.BackgroundSecondary())
+}
+
+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..20782645888d232a5253e2070f4e7773978b9ddc
--- /dev/null
+++ b/internal/tui/components/completions/item.go
@@ -0,0 +1,247 @@
+package completions
+
+import (
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
+ "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"
+ "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
+}
+
+func NewCompletionItem(text string, value any, matchIndexes ...int) CompletionItem {
+ return &completionItemCmp{
+ text: text,
+ value: value,
+ matchIndexes: matchIndexes,
+ }
+}
+
+// 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 := theme.CurrentTheme()
+
+ baseStyle := styles.BaseStyle().Background(t.BackgroundSecondary())
+ titleStyle := baseStyle.Padding(0, 1).Width(c.width).Foreground(t.Text())
+ titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
+
+ if c.focus {
+ titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
+ titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
+ }
+
+ var truncatedTitle string
+ var adjustedMatchIndexes []int
+
+ availableWidth := c.width - 2 // Account for padding
+ if len(c.matchIndexes) > 0 && len(c.text) > availableWidth {
+ // Smart truncation: ensure the last matching part is visible
+ truncatedTitle, adjustedMatchIndexes = c.smartTruncate(c.text, availableWidth, c.matchIndexes)
+ } else {
+ // No matches, use regular truncation
+ truncatedTitle = ansi.Truncate(c.text, availableWidth, "…")
+ adjustedMatchIndexes = c.matchIndexes
+ }
+
+ text := titleStyle.Render(truncatedTitle)
+ if len(adjustedMatchIndexes) > 0 {
+ var ranges []lipgloss.Range
+ for _, rng := range matchedRanges(adjustedMatchIndexes) {
+ // 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(text, rng)
+ ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
+ }
+ text = lipgloss.StyleRanges(text, ranges...)
+ }
+ return tea.NewView(text)
+}
+
+// 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
+ for i := range c.matchIndexes {
+ c.matchIndexes[i] += 1 // Adjust for the padding we add in View
+ }
+}
+
+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, []int) {
+ if width <= 0 {
+ return "", []int{}
+ }
+
+ textLen := ansi.StringWidth(text)
+ if textLen <= width {
+ return text, matchIndexes
+ }
+
+ if len(matchIndexes) == 0 {
+ return ansi.Truncate(text, width, "…"), []int{}
+ }
+
+ // 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, "…"), matchIndexes
+ }
+
+ // 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
+
+ // Adjust match indexes for the new truncated string
+ adjustedIndexes := []int{}
+ for _, idx := range matchIndexes {
+ if idx >= startBytePos {
+ newIdx := idx - startBytePos + 1 //
+ // Check if this match is still within the truncated string
+ if newIdx < len(truncatedText) {
+ adjustedIndexes = append(adjustedIndexes, newIdx)
+ }
+ }
+ }
+
+ return truncatedText, adjustedIndexes
+}
+
+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..c135df01bfe4774d9bef57da4b6cfc28e4034405
--- /dev/null
+++ b/internal/tui/components/completions/keys.go
@@ -0,0 +1,53 @@
+package completions
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/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/list/list.go b/internal/tui/components/core/list/list.go
index c1a678ab0a4af6fce0cd62f7c8972e7656c7e8ae..3a7290967a96382fc86e2fc7d1e9aeba6fede8c8 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -39,6 +39,7 @@ type ListModel interface {
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
+ Filter(string) tea.Cmd // Filter items based on a search term
}
// HasAnim interface identifies items that support animation.
@@ -50,13 +51,11 @@ type HasAnim interface {
// HasFilterValue interface allows items to provide a filter value for searching.
type HasFilterValue interface {
- util.Model
FilterValue() string // Returns a string value used for filtering/searching
}
// HasMatchIndexes interface allows items to set matched character indexes.
type HasMatchIndexes interface {
- util.Model
MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
}
@@ -134,10 +133,11 @@ type model struct {
gapSize int // Number of empty lines between items
padding []int // Padding around the list content
- filterable bool // Whether items can be filtered
- filteredItems []util.Model // Filtered items based on current search
- input textinput.Model // Input field for filtering items
- currentSearch string // Current search term for filtering
+ filterable bool // Whether items can be filtered
+ filteredItems []util.Model // Filtered items based on current search
+ input textinput.Model // Input field for filtering items
+ hideFilterInput bool // Whether to hide the filter input field
+ currentSearch string // Current search term for filtering
}
// listOptions is a function type for configuring list options.
@@ -188,6 +188,13 @@ func WithFilterable(filterable bool) listOptions {
}
}
+// WithHideFilterInput hides the filter input field.
+func WithHideFilterInput(hide bool) listOptions {
+ return func(m *model) {
+ m.hideFilterInput = hide
+ }
+}
+
// 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.
@@ -206,7 +213,7 @@ func New(opts ...listOptions) ListModel {
opt(m)
}
- if m.filterable {
+ if m.filterable && !m.hideFilterInput {
ti := textinput.New()
ti.Placeholder = "Type to filter..."
ti.SetVirtualCursor(false)
@@ -259,7 +266,7 @@ func (m *model) View() tea.View {
Height(m.viewState.height).
Render(m.viewState.content)
- if m.filterable {
+ if m.filterable && !m.hideFilterInput {
content = lipgloss.JoinVertical(
lipgloss.Left,
m.inputStyle().Render(m.input.View()),
@@ -267,7 +274,7 @@ func (m *model) View() tea.View {
)
}
view := tea.NewView(content)
- if m.filterable {
+ if m.filterable && !m.hideFilterInput {
view.SetCursor(m.input.Cursor())
}
return view
@@ -294,15 +301,15 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keyMap.End):
return m, m.goToBottom()
default:
- if !m.filterable {
- return m, nil // Ignore other keys if not filterable
+ 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())
+ cmd = m.Filter(m.input.Value())
cmds = append(cmds, cmd)
}
m.currentSearch = m.input.Value()
@@ -923,7 +930,7 @@ func (m *model) GetSize() (int, int) {
// 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 {
+ if m.filterable && !m.hideFilterInput {
height -= 2 // adjust for input field height and border
}
@@ -936,7 +943,7 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
}
m.viewState.width = width
m.ResetView()
- if m.filterable {
+ if m.filterable && !m.hideFilterInput {
m.input.SetWidth(m.getItemWidth() - 3)
}
return m.setAllItemsSize()
@@ -1152,7 +1159,7 @@ func (m *model) flattenSections(sections []section) []util.Model {
return result
}
-func (m *model) filter(search string) tea.Cmd {
+func (m *model) Filter(search string) tea.Cmd {
var cmds []tea.Cmd
search = strings.TrimSpace(search)
search = strings.ToLower(search)
@@ -1189,6 +1196,7 @@ func (m *model) filter(search string) tea.Cmd {
// 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()
diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go
deleted file mode 100644
index 5c289ddd25bd44f6d4ae070eef73b995ed3fd00b..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/arguments.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package dialog
-
-import (
- "fmt"
-
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textinput"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
-
- "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.SetWidth(40)
- ti.Prompt = ""
- styles := ti.Styles()
- styles.Focused.Placeholder = styles.Focused.Placeholder.Background(t.Background())
- styles.Blurred.Placeholder = styles.Blurred.Placeholder.Background(t.Background())
- styles.Focused.Suggestion = styles.Focused.Suggestion.Background(t.Background()).Foreground(t.Primary())
- styles.Blurred.Suggestion = styles.Blurred.Suggestion.Background(t.Background())
- styles.Focused.Text = styles.Focused.Text.Background(t.Background()).Foreground(t.Primary())
- styles.Blurred.Text = styles.Blurred.Text.Background(t.Background())
-
- // Only focus the first input initially
- if i == 0 {
- ti.Focus()
- } 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
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- 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()
- 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()
- 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()
- }
- 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()
-}
diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go
deleted file mode 100644
index 1e60d3ed1f317c2729ce33ae3e62f5867a2245e6..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/commands.go
+++ /dev/null
@@ -1,182 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- 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 {
- util.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.KeyPressMsg:
- 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() tea.View {
- 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().String()),
- baseStyle.Width(maxWidth).Render(""),
- )
-
- return tea.NewView(
- 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 d5cf1519a91c1cd5c6e3572cb33f43f84d64b7e6..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/complete.go
+++ /dev/null
@@ -1,264 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textarea"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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 {
- util.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.KeyPressMsg:
- 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() tea.View {
- 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 tea.NewView(
- 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().String()),
- )
-}
-
-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 dd2ae57148ee07ef1a88087d93525a4f439bdc54..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/custom_commands.go
+++ /dev/null
@@ -1,185 +0,0 @@
-package dialog
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "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, 0o755); 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 c21eaaa548adc563b6dc4c75125c588c9782b061..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/custom_commands_test.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package dialog
-
-import (
- "regexp"
- "testing"
-)
-
-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)
- }
- }
-}
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index 55cfefd5af592854cb38161f0e7e546a6e71b295..07292ae123a4b220c01fd9e51c9e0754634ca561 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/components/chat"
+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
"github.com/opencode-ai/opencode/internal/tui/components/core/list"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/styles"
@@ -75,9 +76,9 @@ func (c *commandDialogCmp) Init() tea.Cmd {
commandItems := []util.Model{}
if len(commands) > 0 {
- commandItems = append(commandItems, NewItemSection("Custom"))
+ commandItems = append(commandItems, NewItemSection("Custom Commands"))
for _, cmd := range commands {
- commandItems = append(commandItems, NewCommandItem(cmd))
+ commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
}
}
@@ -85,7 +86,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
for _, cmd := range c.defaultCommands() {
c.commands = append(c.commands, cmd)
- commandItems = append(commandItems, NewCommandItem(cmd))
+ commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
}
c.commandList.SetItems(commandItems)
@@ -106,7 +107,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, nil // No item selected, do nothing
}
items := c.commandList.Items()
- selectedItem := items[selectedItemInx].(CommandItem).Command()
+ selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
return c, tea.Sequence(
util.CmdHandler(dialogs.CloseDialogMsg{}),
selectedItem.Handler(selectedItem),
diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go
index e656c1c3a6133763f7bc4bc78c438b9c84f4c3b1..26974d5082046aaa05477f95fddfdeca889c98dc 100644
--- a/internal/tui/components/dialogs/commands/item.go
+++ b/internal/tui/components/dialogs/commands/item.go
@@ -11,153 +11,8 @@ import (
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
- "github.com/rivo/uniseg"
)
-type CommandItem interface {
- util.Model
- layout.Focusable
- layout.Sizeable
- Command() Command
-}
-
-type commandItem struct {
- width int
- command Command
- focus bool
- matchIndexes []int
-}
-
-func NewCommandItem(command Command) CommandItem {
- return &commandItem{
- command: command,
- matchIndexes: make([]int, 0),
- }
-}
-
-// Init implements CommandItem.
-func (c *commandItem) Init() tea.Cmd {
- return nil
-}
-
-// Update implements CommandItem.
-func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
- return c, nil
-}
-
-// View implements CommandItem.
-func (c *commandItem) View() tea.View {
- t := theme.CurrentTheme()
-
- baseStyle := styles.BaseStyle()
- titleStyle := baseStyle.Width(c.width).Foreground(t.Text())
- titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
-
- if c.focus {
- titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
- titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
- }
- var ranges []lipgloss.Range
- truncatedTitle := ansi.Truncate(c.command.Title, c.width, "…")
- text := titleStyle.Render(truncatedTitle)
- if len(c.matchIndexes) > 0 {
- 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(text, rng)
- ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
- }
- text = lipgloss.StyleRanges(text, ranges...)
- }
- return tea.NewView(text)
-}
-
-// Command implements CommandItem.
-func (c *commandItem) Command() Command {
- return c.command
-}
-
-// Blur implements CommandItem.
-func (c *commandItem) Blur() tea.Cmd {
- c.focus = false
- return nil
-}
-
-// Focus implements CommandItem.
-func (c *commandItem) Focus() tea.Cmd {
- c.focus = true
- return nil
-}
-
-// IsFocused implements CommandItem.
-func (c *commandItem) IsFocused() bool {
- return c.focus
-}
-
-// GetSize implements CommandItem.
-func (c *commandItem) GetSize() (int, int) {
- return c.width, 2
-}
-
-// SetSize implements CommandItem.
-func (c *commandItem) SetSize(width int, height int) tea.Cmd {
- c.width = width
- return nil
-}
-
-func (c *commandItem) FilterValue() string {
- return c.command.Title
-}
-
-func (c *commandItem) MatchIndexes(indexes []int) {
- c.matchIndexes = indexes
-}
-
-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
-}
-
type ItemSection interface {
util.Model
layout.Sizeable
diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go
index 92166ca02e9f934db50a226d5b357736031ab4d3..684e95df2509af4a3af2eb6b9146f27935a22d8a 100644
--- a/internal/tui/page/chat.go
+++ b/internal/tui/page/chat.go
@@ -2,18 +2,16 @@ package page
import (
"context"
- "strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/completions"
"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/chat"
- "github.com/opencode-ai/opencode/internal/tui/components/dialog"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -21,26 +19,19 @@ import (
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
+ app *app.App
+ editor layout.Container
+ messages layout.Container
+ layout layout.SplitPaneLayout
+ session session.Session
}
type ChatKeyMap struct {
- ShowCompletionDialog key.Binding
- NewSession key.Binding
- Cancel 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"),
@@ -52,11 +43,7 @@ var keyMap = ChatKeyMap{
}
func (p *chatPage) Init() tea.Cmd {
- cmds := []tea.Cmd{
- p.layout.Init(),
- p.completionDialog.Init(),
- }
- return tea.Batch(cmds...)
+ return p.layout.Init()
}
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -66,31 +53,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
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:
+ 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...")
}
- // 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)
+ cmd := p.sendMessage(msg.Content, nil)
if cmd != nil {
return p, cmd
}
@@ -104,9 +79,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.session = msg
case tea.KeyPressMsg:
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(
@@ -122,19 +94,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
- 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.KeyPressMsg); 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)
@@ -186,30 +145,7 @@ func (p *chatPage) GetSize() (int, int) {
}
func (p *chatPage) View() tea.View {
- layoutView := p.layout.View()
-
- if p.showCompletionDialog {
- _, layoutHeight := p.layout.GetSize()
- editorWidth, editorHeight := p.editor.GetSize()
-
- p.completionDialog.SetWidth(editorWidth)
- overlay := p.completionDialog.View()
-
- viewStr := layout.PlaceOverlay(
- 0,
- layoutHeight-editorHeight-lipgloss.Height(overlay.String()),
- overlay.String(),
- layoutView.String(),
- false,
- )
-
- view := tea.NewView(viewStr)
- view.SetCursor(overlay.Cursor())
- return view
- }
-
- logging.Info("Cursor in page", "c", layoutView.Cursor())
- return layoutView
+ return p.layout.View()
}
func (p *chatPage) BindingKeys() []key.Binding {
@@ -220,22 +156,18 @@ func (p *chatPage) BindingKeys() []key.Binding {
}
func NewChatPage(app *app.App) util.Model {
- cg := completions.NewFileAndFolderContextGroup()
- completionDialog := dialog.NewCompletionDialogCmp(cg)
-
messagesContainer := layout.NewContainer(
chat.NewMessagesListCmp(app),
layout.WithPadding(1, 1, 0, 1),
)
editorContainer := layout.NewContainer(
- chat.NewEditorCmp(app),
+ editor.NewEditorCmp(app),
layout.WithBorder(true, false, false, false),
)
return &chatPage{
- app: app,
- editor: editorContainer,
- messages: messagesContainer,
- completionDialog: completionDialog,
+ app: app,
+ editor: editorContainer,
+ messages: messagesContainer,
layout: layout.NewSplitPane(
layout.WithLeftPanel(messagesContainer),
layout.WithBottomPanel(editorContainer),
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index f2b99e0711915c402583c05ca77142d3a047af6c..9e8a62a676e3e89545984de7903e3e32d48d58ea 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -7,6 +7,7 @@ import (
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/pubsub"
+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
"github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
@@ -30,7 +31,8 @@ type appModel struct {
app *app.App
- dialog dialogs.DialogCmp
+ dialog dialogs.DialogCmp
+ completions completions.Completions
}
func (a appModel) Init() tea.Cmd {
@@ -52,6 +54,12 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
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)
@@ -128,6 +136,24 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
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):
return util.CmdHandler(dialogs.OpenDialogMsg{
@@ -191,28 +217,38 @@ func (a *appModel) View() tea.View {
components = append(components, a.status.View().String())
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
-
+ layers := []*lipgloss.Layer{
+ lipgloss.NewLayer(appView),
+ }
t := theme.CurrentTheme()
if a.dialog.HasDialogs() {
- layers := append(
- []*lipgloss.Layer{
- lipgloss.NewLayer(appView),
- },
+ layers = append(
+ layers,
a.dialog.GetLayers()...,
)
- canvas := lipgloss.NewCanvas(
- layers...,
+ }
+
+ cursor := pageView.Cursor()
+ activeView := a.dialog.ActiveView()
+ if activeView != nil {
+ cursor = activeView.Cursor()
+ }
+
+ 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),
)
- view := tea.NewView(canvas.Render())
- activeView := a.dialog.ActiveView()
- view.SetBackgroundColor(t.Background())
- view.SetCursor(activeView.Cursor())
- return view
}
- view := tea.NewView(appView)
- view.SetCursor(pageView.Cursor())
+ canvas := lipgloss.NewCanvas(
+ layers...,
+ )
+ view := tea.NewView(canvas.Render())
view.SetBackgroundColor(t.Background())
+ view.SetCursor(cursor)
return view
}
@@ -230,7 +266,8 @@ func New(app *app.App) tea.Model {
page.LogsPage: page.NewLogsPage(),
},
- dialog: dialogs.NewDialogCmp(),
+ dialog: dialogs.NewDialogCmp(),
+ completions: completions.New(),
}
return model
From aad40903526ec14c0d9ae355514d4c7aff1eed3d Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sun, 1 Jun 2025 01:04:05 +0200
Subject: [PATCH 33/73] wip theme + new UI
---
.opencode.json | 4 +-
cspell.json | 2 +-
diff.diff | 124 ++++++++++
go.mod | 1 +
go.sum | 2 +
internal/tui/components/chat/chat.go | 2 +-
internal/tui/components/chat/editor/editor.go | 64 ++---
.../tui/components/chat/sidebar/sidebar.go | 205 ++++++++++++++++
internal/tui/components/core/helpers.go | 63 +++++
internal/tui/components/logo/logo.go | 2 +-
internal/tui/layout/container.go | 9 +-
internal/tui/layout/split.go | 50 +++-
internal/tui/page/chat/chat.go | 173 +++++++++++++
internal/tui/page/chat/keys.go | 1 +
internal/tui/styles/crush.go | 44 ++++
internal/tui/styles/theme.go | 229 ++++++++++++++++++
internal/tui/tui.go | 23 +-
todos.md | 8 +
18 files changed, 938 insertions(+), 68 deletions(-)
create mode 100644 diff.diff
create mode 100644 internal/tui/components/chat/sidebar/sidebar.go
create mode 100644 internal/tui/components/core/helpers.go
create mode 100644 internal/tui/page/chat/chat.go
create mode 100644 internal/tui/page/chat/keys.go
create mode 100644 internal/tui/styles/crush.go
create mode 100644 internal/tui/styles/theme.go
create mode 100644 todos.md
diff --git a/.opencode.json b/.opencode.json
index 75e357de711e3a49ea37519f9cd91f21bba8a25f..acb2b7ccb04ceb05130449ffccdcf2ee8567dd03 100644
--- a/.opencode.json
+++ b/.opencode.json
@@ -1,11 +1,11 @@
{
"$schema": "./opencode-schema.json",
"lsp": {
- "gopls": {
+ "Go": {
"command": "gopls"
}
},
"tui": {
- "theme": "opencode-dark"
+ "theme": "charm"
}
}
diff --git a/cspell.json b/cspell.json
index 9881e74f5d62a4b87631a2fd1ce372e2ebee804c..c2fdb29fd8f1b777049f2df44c43633a16384245 100644
--- a/cspell.json
+++ b/cspell.json
@@ -1 +1 @@
-{"flagWords":[],"language":"en","words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable"],"version":"0.2"}
\ No newline at end of file
+{"words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps"],"version":"0.2","language":"en","flagWords":[]}
\ No newline at end of file
diff --git a/diff.diff b/diff.diff
new file mode 100644
index 0000000000000000000000000000000000000000..e22ae61ef5e96692b9e0d5dbf4b1ad1b7ea578b0
--- /dev/null
+++ b/diff.diff
@@ -0,0 +1,124 @@
+diff --git a/.opencode.json b/.opencode.json
+index 75e357d..59be1e8 100644
+--- a/.opencode.json
++++ b/.opencode.json
+@@ -6,6 +6,6 @@
+ }
+ },
+ "tui": {
+- "theme": "opencode-dark"
++ "theme": "charm"
+ }
+ }
+diff --git a/go.mod b/go.mod
+index 18ad042..940a8e8 100644
+--- a/go.mod
++++ b/go.mod
+@@ -36,6 +36,8 @@ require (
+ github.com/stretchr/testify v1.10.0
+ )
+
++require github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 // indirect
++
+ require (
+ cloud.google.com/go v0.116.0 // indirect
+ cloud.google.com/go/auth v0.13.0 // indirect
+diff --git a/go.sum b/go.sum
+index f6e08b7..8f347ed 100644
+--- a/go.sum
++++ b/go.sum
+@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB
+ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/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=
+diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
+index 2ee0b04..52c4dae 100644
+--- a/internal/tui/components/chat/chat.go
++++ b/internal/tui/components/chat/chat.go
+@@ -95,7 +95,7 @@ func lspsConfigured() string {
+ func logoBlock() string {
+ t := theme.CurrentTheme()
+ return logo.Render(version.Version, true, logo.Opts{
+- FieldColor: t.Accent(),
++ FieldColor: t.Secondary(),
+ TitleColorA: t.Primary(),
+ TitleColorB: t.Secondary(),
+ CharmColor: t.Primary(),
+diff --git a/internal/tui/tui.go b/internal/tui/tui.go
+index 9e8a62a..3f07956 100644
+--- a/internal/tui/tui.go
++++ b/internal/tui/tui.go
+@@ -18,6 +18,7 @@ import (
+ "github.com/opencode-ai/opencode/internal/tui/util"
+ )
+
++// appModel represents the main application model that manages pages, dialogs, and UI state.
+ type appModel struct {
+ width, height int
+ keyMap KeyMap
+@@ -35,6 +36,7 @@ type appModel struct {
+ completions completions.Completions
+ }
+
++// 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()
+@@ -46,6 +48,7 @@ func (a appModel) Init() tea.Cmd {
+ return tea.Batch(cmds...)
+ }
+
++// 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
+@@ -111,6 +114,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ return a, tea.Batch(cmds...)
+ }
+
++// handleWindowResize processes window resize events and updates all components.
+ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+ var cmds []tea.Cmd
+ msg.Height -= 1 // Make space for the status bar
+@@ -134,6 +138,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
+ return tea.Batch(cmds...)
+ }
+
++// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
+ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+ switch {
+ // completions
+@@ -182,11 +187,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+ }
+ }
+
+-// RegisterCommand adds a command to the command dialog
+-// func (a *appModel) RegisterCommand(cmd dialog.Command) {
+-// a.commands = append(a.commands, cmd)
+-// }
+-
++// 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
+@@ -209,6 +210,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
+ return tea.Batch(cmds...)
+ }
+
++// 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{
+@@ -252,6 +254,7 @@ func (a *appModel) View() tea.View {
+ return view
+ }
+
++// New creates and initializes a new TUI application model.
+ func New(app *app.App) tea.Model {
+ startPage := page.ChatPage
+ model := &appModel{
diff --git a/go.mod b/go.mod
index 0fb1b62102f0a7a3ed14652c28c1bf814a480fdf..b43b828f687cb11429c02f91fa7376af9bbe54ca 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
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.20250516160309-24eee56f89fa
+ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
github.com/fsnotify/fsnotify v1.8.0
github.com/go-logfmt/logfmt v0.6.0
github.com/google/uuid v1.6.0
diff --git a/go.sum b/go.sum
index c60ac51e573f37283022305dce9e10f9c2f0ed5f..2ab3f666ec32193eec797d86119fc31b30255b75 100644
--- a/go.sum
+++ b/go.sum
@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB
github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/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=
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index 2ee0b042315c5608ccb1d4aacf5e25f531c20a92..52c4daeacd2758a82bbfa87a81f3e3f642c1972f 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -95,7 +95,7 @@ func lspsConfigured() string {
func logoBlock() string {
t := theme.CurrentTheme()
return logo.Render(version.Version, true, logo.Opts{
- FieldColor: t.Accent(),
+ FieldColor: t.Secondary(),
TitleColorA: t.Primary(),
TitleColorB: t.Secondary(),
CharmColor: t.Primary(),
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index c0f17d6f78fd579b42e1ca55acc1b7b4f7b00e8a..b18ec71d8f7812e60931e02605bc3ed7784a76f7 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -261,29 +261,25 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *editorCmp) View() tea.View {
- t := theme.CurrentTheme()
-
- // Style the prompt with theme colors
- style := lipgloss.NewStyle().
- Padding(0, 0, 0, 1).
- Bold(true).
- Foreground(t.Primary())
-
+ t := styles.CurrentTheme()
cursor := m.textarea.Cursor()
- cursor.X = cursor.X + m.x + 2
- cursor.Y = cursor.Y + m.y + 1
+ cursor.X = cursor.X + m.x + 1
+ cursor.Y = cursor.Y + m.y + 1 // adjust for padding
if len(m.attachments) == 0 {
- view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
+ content := t.S().Base.Padding(1).Render(
+ m.textarea.View(),
+ )
+ view := tea.NewView(content)
view.SetCursor(cursor)
return view
}
- m.textarea.SetHeight(m.height - 1)
- view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top,
- m.attachmentsContent(),
- lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
+ 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
}
@@ -291,8 +287,8 @@ func (m *editorCmp) View() tea.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 - 2) // adjust for padding
+ m.textarea.SetHeight(height - 2) // adjust for padding
return nil
}
@@ -359,32 +355,18 @@ func (m *editorCmp) startCompletions() tea.Msg {
}
func CreateTextArea(existing *textarea.Model) textarea.Model {
- t := theme.CurrentTheme()
- bgColor := t.Background()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
+ t := styles.CurrentTheme()
ta := textarea.New()
- s := textarea.DefaultDarkStyles()
- b := s.Blurred
- b.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
- b.CursorLine = styles.BaseStyle().Background(bgColor)
- b.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
- b.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-
- f := s.Focused
- f.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
- f.CursorLine = styles.BaseStyle().Background(bgColor)
- f.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
- f.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-
- s.Focused = f
- s.Blurred = b
- ta.SetStyles(s)
-
- ta.Prompt = " "
+ ta.SetStyles(t.S().TextArea)
+ ta.SetPromptFunc(2, func(lineIndex int) string {
+ if lineIndex == 0 {
+ return "> "
+ }
+ return t.S().Muted.Render(": ")
+ })
ta.ShowLineNumbers = false
ta.CharLimit = -1
+ ta.Placeholder = "Tell me more about this project..."
ta.SetVirtualCursor(false)
if existing != nil {
diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go
new file mode 100644
index 0000000000000000000000000000000000000000..c362a3723617398f3d54c9e33142de800c812afb
--- /dev/null
+++ b/internal/tui/components/chat/sidebar/sidebar.go
@@ -0,0 +1,205 @@
+package sidebar
+
+import (
+ "os"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/config"
+ "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/logo"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+ "github.com/opencode-ai/opencode/internal/version"
+)
+
+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 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("Configured LSPs", maxWidth),
+ )
+
+ lspList := []string{section, ""}
+
+ lsp := config.Get().LSP
+ if len(lsp) == 0 {
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ section,
+ "",
+ t.S().Muted.Render("No LSPs configured."),
+ )
+ }
+
+ 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("Configured MCPs", maxWidth),
+ )
+
+ mcpList := []string{section, ""}
+
+ mcp := config.Get().MCPServers
+ if len(mcp) == 0 {
+ return lipgloss.JoinVertical(
+ lipgloss.Left,
+ section,
+ "",
+ t.S().Muted.Render("No MCPs configured."),
+ )
+ }
+
+ 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/core/helpers.go b/internal/tui/components/core/helpers.go
new file mode 100644
index 0000000000000000000000000000000000000000..b5fbb7ed9f113c5d9c6274a030e00532dd2aea4b
--- /dev/null
+++ b/internal/tui/components/core/helpers.go
@@ -0,0 +1,63 @@
+package core
+
+import (
+ "image/color"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+)
+
+func Section(title string, width int) string {
+ t := styles.CurrentTheme()
+ char := "─"
+ length := len(title) + 1
+ remainingWidth := width - length
+ if remainingWidth > 0 {
+ title = title + " " + t.S().Subtle.Render(strings.Repeat(char, remainingWidth))
+ }
+ 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,
+ }, " ")
+}
diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go
index 15b5f97e66fb0144cdf0bf65db6604270e2c196c..06ece3055be1494dcae2693cb2ab5e4fcef036bf 100644
--- a/internal/tui/components/logo/logo.go
+++ b/internal/tui/components/logo/logo.go
@@ -63,7 +63,7 @@ func Render(version string, compact bool, o Opts) string {
// Narrow version.
if compact {
field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
- return strings.Join([]string{field, field, crush, field}, "\n")
+ return strings.Join([]string{field, field, crush, field, ""}, "\n")
}
fieldHeight := lipgloss.Height(crush)
diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go
index 923f29e6b284086cd00dc52b181d8933d3801eaf..aab6566f8a0459c66dbebc7872cb8af6c2ff3654 100644
--- a/internal/tui/layout/container.go
+++ b/internal/tui/layout/container.go
@@ -4,7 +4,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -46,12 +46,11 @@ func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (c *container) View() tea.View {
- t := theme.CurrentTheme()
- style := lipgloss.NewStyle()
+ 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 {
@@ -69,7 +68,7 @@ func (c *container) View() tea.View {
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).
diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go
index bfd98b5059165f974283b4f3efb7b67713f1a41c..2eded093a4ef43708fb08183ceac0dd28105c18c 100644
--- a/internal/tui/layout/split.go
+++ b/internal/tui/layout/split.go
@@ -22,14 +22,18 @@ type SplitPaneLayout interface {
}
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)
@@ -141,8 +145,17 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
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
@@ -150,8 +163,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
@@ -260,8 +282,8 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
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)
@@ -298,3 +320,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/chat.go b/internal/tui/page/chat/chat.go
new file mode 100644
index 0000000000000000000000000000000000000000..7a0cffd814ebf968e82667b3295634f581e36845
--- /dev/null
+++ b/internal/tui/page/chat/chat.go
@@ -0,0 +1,173 @@
+package chat
+
+import (
+ "context"
+
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/opencode-ai/opencode/internal/app"
+ "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/chat/editor"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat/sidebar"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/opencode-ai/opencode/internal/tui/page"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+var ChatPage page.PageID = "chat"
+
+type chatPage struct {
+ app *app.App
+
+ layout layout.SplitPaneLayout
+
+ session session.Session
+}
+
+type ChatKeyMap struct {
+ NewSession key.Binding
+ Cancel key.Binding
+}
+
+var keyMap = ChatKeyMap{
+ 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 {
+ return p.layout.Init()
+}
+
+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, keyMap.NewSession):
+ p.session = session.Session{}
+ return p, tea.Batch(
+ p.clearMessages(),
+ 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
+ }
+ }
+ }
+ 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 (p *chatPage) BindingKeys() []key.Binding {
+ bindings := layout.KeyMapToSlice(keyMap)
+ return bindings
+}
+
+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(3),
+ layout.WithFixedRightWidth(31),
+ ),
+ }
+}
diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..5c2cd9a8199252f90f39ea9c09c8e1f285a06855
--- /dev/null
+++ b/internal/tui/page/chat/keys.go
@@ -0,0 +1 @@
+package chat
diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go
new file mode 100644
index 0000000000000000000000000000000000000000..4c8072fab55e9b67483809fc1292d87bf78b728b
--- /dev/null
+++ b/internal/tui/styles/crush.go
@@ -0,0 +1,44 @@
+package styles
+
+import (
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/exp/charmtone"
+)
+
+func NewCrushTheme() *Theme {
+ return &Theme{
+ Name: "crush",
+ IsDark: true,
+
+ Primary: lipgloss.Color(charmtone.Charple.Hex()),
+ Secondary: lipgloss.Color(charmtone.Dolly.Hex()),
+ Tertiary: lipgloss.Color(charmtone.Bok.Hex()),
+ Accent: lipgloss.Color(charmtone.Zest.Hex()),
+
+ // Backgrounds
+ BgBase: lipgloss.Color(charmtone.Pepper.Hex()),
+ BgSubtle: lipgloss.Color(charmtone.Charcoal.Hex()),
+ BgOverlay: lipgloss.Color(charmtone.Iron.Hex()),
+
+ // Foregrounds
+ FgBase: lipgloss.Color(charmtone.Ash.Hex()),
+ FgMuted: lipgloss.Color(charmtone.Squid.Hex()),
+ FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()),
+
+ // Borders
+ Border: lipgloss.Color(charmtone.Charcoal.Hex()),
+ BorderFocus: lipgloss.Color(charmtone.Charple.Hex()),
+
+ // Status
+ Success: lipgloss.Color(charmtone.Guac.Hex()),
+ Error: lipgloss.Color(charmtone.Sriracha.Hex()),
+ Warning: lipgloss.Color(charmtone.Uni.Hex()),
+ Info: lipgloss.Color(charmtone.Malibu.Hex()),
+
+ // TODO: fix this.
+ SyntaxBg: lipgloss.Color("#1C1C1F"),
+ SyntaxKeyword: lipgloss.Color("#FF6DFE"),
+ SyntaxString: lipgloss.Color("#E8FE96"),
+ SyntaxComment: lipgloss.Color("#6B6F85"),
+ }
+}
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
new file mode 100644
index 0000000000000000000000000000000000000000..03bf9004a410d04cec240148c7e7f2950afc8888
--- /dev/null
+++ b/internal/tui/styles/theme.go
@@ -0,0 +1,229 @@
+package styles
+
+import (
+ "fmt"
+ "image/color"
+
+ "github.com/charmbracelet/bubbles/v2/textarea"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+)
+
+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
+ FgSubtle color.Color
+
+ Border color.Color
+ BorderFocus color.Color
+
+ Success color.Color
+ Error color.Color
+ Warning color.Color
+ Info color.Color
+
+ // TODO: add more syntax colors, maybe just use a chroma theme here.
+ SyntaxBg color.Color
+ SyntaxKeyword color.Color
+ SyntaxString color.Color
+ SyntaxComment color.Color
+
+ styles *Styles
+}
+
+type Styles struct {
+ Base lipgloss.Style
+
+ Title lipgloss.Style
+ Subtitle lipgloss.Style
+ Text lipgloss.Style
+ Muted lipgloss.Style
+ Subtle lipgloss.Style
+
+ Success lipgloss.Style
+ Error lipgloss.Style
+ Warning lipgloss.Style
+ Info lipgloss.Style
+
+ // Inputs
+ TextArea textarea.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().
+ Background(t.BgBase).
+ Foreground(t.FgBase)
+ return &Styles{
+ Base: base,
+
+ Title: base.
+ Foreground(t.Accent).
+ Bold(true),
+
+ Subtitle: base.
+ Foreground(t.Secondary).
+ Bold(true),
+
+ Text: base,
+
+ 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),
+
+ 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,
+ },
+ },
+ }
+}
+
+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),
+ }
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 9e8a62a676e3e89545984de7903e3e32d48d58ea..2cb0af4c681f232daea4f36978db5f4c9e18f885 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -14,10 +14,12 @@ import (
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
"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/page/chat"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/util"
)
+// appModel represents the main application model that manages pages, dialogs, and UI state.
type appModel struct {
width, height int
keyMap KeyMap
@@ -35,6 +37,7 @@ type appModel struct {
completions completions.Completions
}
+// 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()
@@ -46,6 +49,7 @@ func (a appModel) Init() tea.Cmd {
return tea.Batch(cmds...)
}
+// 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
@@ -111,6 +115,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...)
}
+// handleWindowResize processes window resize events and updates all components.
func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
var cmds []tea.Cmd
msg.Height -= 1 // Make space for the status bar
@@ -134,6 +139,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
return tea.Batch(cmds...)
}
+// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
switch {
// completions
@@ -182,11 +188,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
}
-// RegisterCommand adds a command to the command dialog
-// func (a *appModel) RegisterCommand(cmd dialog.Command) {
-// a.commands = append(a.commands, cmd)
-// }
-
+// 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
@@ -209,6 +211,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
return tea.Batch(cmds...)
}
+// 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{
@@ -220,7 +223,6 @@ func (a *appModel) View() tea.View {
layers := []*lipgloss.Layer{
lipgloss.NewLayer(appView),
}
- t := theme.CurrentTheme()
if a.dialog.HasDialogs() {
layers = append(
layers,
@@ -246,12 +248,15 @@ func (a *appModel) View() tea.View {
canvas := lipgloss.NewCanvas(
layers...,
)
+
+ t := styles.CurrentTheme()
view := tea.NewView(canvas.Render())
- view.SetBackgroundColor(t.Background())
+ 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
model := &appModel{
@@ -262,7 +267,7 @@ func New(app *app.App) tea.Model {
keyMap: DefaultKeyMap(),
pages: map[page.PageID]util.Model{
- page.ChatPage: page.NewChatPage(app),
+ page.ChatPage: chat.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
},
diff --git a/todos.md b/todos.md
new file mode 100644
index 0000000000000000000000000000000000000000..fd87bfff909fd8d05aa4fc3012656a435eb4c717
--- /dev/null
+++ b/todos.md
@@ -0,0 +1,8 @@
+# Chat Page
+
+## Landing page
+
+- [ ] Implement the logo landing page
+- [ ] Add cwd improved
+- [ ] Implement Active LSPs
+- [ ] Implement Active MCPs
From 83fee216eabaa3926e0208f6682a58461e3a5269 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Sat, 31 May 2025 23:45:53 -0400
Subject: [PATCH 34/73] fix: reference Charmtone colors directly in theme
---
internal/tui/styles/crush.go | 32 ++++++++++++++++----------------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go
index 4c8072fab55e9b67483809fc1292d87bf78b728b..348e2b5c961895590130a02017d4aeab8dd98a94 100644
--- a/internal/tui/styles/crush.go
+++ b/internal/tui/styles/crush.go
@@ -10,30 +10,30 @@ func NewCrushTheme() *Theme {
Name: "crush",
IsDark: true,
- Primary: lipgloss.Color(charmtone.Charple.Hex()),
- Secondary: lipgloss.Color(charmtone.Dolly.Hex()),
- Tertiary: lipgloss.Color(charmtone.Bok.Hex()),
- Accent: lipgloss.Color(charmtone.Zest.Hex()),
+ Primary: charmtone.Charple,
+ Secondary: charmtone.Dolly,
+ Tertiary: charmtone.Bok,
+ Accent: charmtone.Zest,
// Backgrounds
- BgBase: lipgloss.Color(charmtone.Pepper.Hex()),
- BgSubtle: lipgloss.Color(charmtone.Charcoal.Hex()),
- BgOverlay: lipgloss.Color(charmtone.Iron.Hex()),
+ BgBase: charmtone.Pepper,
+ BgSubtle: charmtone.Charcoal,
+ BgOverlay: charmtone.Iron,
// Foregrounds
- FgBase: lipgloss.Color(charmtone.Ash.Hex()),
- FgMuted: lipgloss.Color(charmtone.Squid.Hex()),
- FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()),
+ FgBase: charmtone.Ash,
+ FgMuted: charmtone.Squid,
+ FgSubtle: charmtone.Oyster,
// Borders
- Border: lipgloss.Color(charmtone.Charcoal.Hex()),
- BorderFocus: lipgloss.Color(charmtone.Charple.Hex()),
+ Border: charmtone.Charcoal,
+ BorderFocus: charmtone.Charple,
// Status
- Success: lipgloss.Color(charmtone.Guac.Hex()),
- Error: lipgloss.Color(charmtone.Sriracha.Hex()),
- Warning: lipgloss.Color(charmtone.Uni.Hex()),
- Info: lipgloss.Color(charmtone.Malibu.Hex()),
+ Success: charmtone.Guac,
+ Error: charmtone.Sriracha,
+ Warning: charmtone.Uni,
+ Info: charmtone.Malibu,
// TODO: fix this.
SyntaxBg: lipgloss.Color("#1C1C1F"),
From dff1bac59a978ecce390ebf62a6d6df42d6da541 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sun, 1 Jun 2025 17:16:09 +0200
Subject: [PATCH 36/73] wip small fixes
---
.../tui/components/chat/sidebar/sidebar.go | 8 +-
internal/tui/components/core/helpers.go | 2 +-
internal/tui/layout/split.go | 9 +-
internal/tui/page/chat/chat.go | 2 +-
internal/tui/styles/markdown.go | 2 -
internal/tui/styles/theme.go | 221 +++++++++++++++++-
6 files changed, 230 insertions(+), 14 deletions(-)
diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go
index c362a3723617398f3d54c9e33142de800c812afb..e8197835a2ef393409940a416380257831f32267 100644
--- a/internal/tui/components/chat/sidebar/sidebar.go
+++ b/internal/tui/components/chat/sidebar/sidebar.go
@@ -115,7 +115,7 @@ func (m *sidebarCmp) lspBlock() string {
t := styles.CurrentTheme()
section := t.S().Muted.Render(
- core.Section("Configured LSPs", maxWidth),
+ core.Section("LSPs", maxWidth),
)
lspList := []string{section, ""}
@@ -126,7 +126,7 @@ func (m *sidebarCmp) lspBlock() string {
lipgloss.Left,
section,
"",
- t.S().Muted.Render("No LSPs configured."),
+ t.S().Base.Foreground(t.Border).Render("None"),
)
}
@@ -158,7 +158,7 @@ func (m *sidebarCmp) mcpBlock() string {
t := styles.CurrentTheme()
section := t.S().Muted.Render(
- core.Section("Configured MCPs", maxWidth),
+ core.Section("MCPs", maxWidth),
)
mcpList := []string{section, ""}
@@ -169,7 +169,7 @@ func (m *sidebarCmp) mcpBlock() string {
lipgloss.Left,
section,
"",
- t.S().Muted.Render("No MCPs configured."),
+ t.S().Base.Foreground(t.Border).Render("None"),
)
}
diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go
index b5fbb7ed9f113c5d9c6274a030e00532dd2aea4b..efc3ac745691f11c0a5769ec7761c5f150f18217 100644
--- a/internal/tui/components/core/helpers.go
+++ b/internal/tui/components/core/helpers.go
@@ -15,7 +15,7 @@ func Section(title string, width int) string {
length := len(title) + 1
remainingWidth := width - length
if remainingWidth > 0 {
- title = title + " " + t.S().Subtle.Render(strings.Repeat(char, remainingWidth))
+ title = title + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth))
}
return title
}
diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go
index 2eded093a4ef43708fb08183ceac0dd28105c18c..6023648a8de14fe9f3a7a13d429d17dfd1f751e9 100644
--- a/internal/tui/layout/split.go
+++ b/internal/tui/layout/split.go
@@ -4,7 +4,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -126,12 +126,11 @@ func (s *splitPaneLayout) View() tea.View {
cursor = s.leftPanel.View().Cursor()
}
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
- style := lipgloss.NewStyle().
+ style := t.S().Base.
Width(s.width).
- Height(s.height).
- Background(t.Background())
+ Height(s.height)
view := tea.NewView(style.Render(finalView))
view.SetCursor(cursor)
diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go
index 7a0cffd814ebf968e82667b3295634f581e36845..d8c1f0eb81d2b86f2898a0ff43a50e5799ad66a9 100644
--- a/internal/tui/page/chat/chat.go
+++ b/internal/tui/page/chat/chat.go
@@ -166,7 +166,7 @@ func NewChatPage(app *app.App) util.Model {
layout: layout.NewSplitPane(
layout.WithRightPanel(sidebarContainer),
layout.WithBottomPanel(editorContainer),
- layout.WithFixedBottomHeight(3),
+ layout.WithFixedBottomHeight(5),
layout.WithFixedRightWidth(31),
),
}
diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go
index 39ab57d14785222dbcf88116bd62060513c01ec4..df6b91bd8e876b4ae44700e7daeb28484d120bd6 100644
--- a/internal/tui/styles/markdown.go
+++ b/internal/tui/styles/markdown.go
@@ -9,8 +9,6 @@ import (
"github.com/opencode-ai/opencode/internal/tui/theme"
)
-const defaultMargin = 1
-
// Helper functions for style pointers
func boolPtr(b bool) *bool { return &b }
func stringPtr(s string) *string { return &s }
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
index 03bf9004a410d04cec240148c7e7f2950afc8888..c14bab9c6bda3772749dcd2f13931d630fac2b46 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -6,9 +6,16 @@ import (
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/glamour/v2/ansi"
"github.com/charmbracelet/lipgloss/v2"
)
+const (
+ defaultListIndent = 2
+ defaultListLevelIndent = 4
+ defaultMargin = 2
+)
+
type Theme struct {
Name string
IsDark bool
@@ -56,6 +63,9 @@ type Styles struct {
Error lipgloss.Style
Warning lipgloss.Style
Info lipgloss.Style
+ // Markdown & Chroma
+
+ Markdown ansi.StyleConfig
// Inputs
TextArea textarea.Styles
@@ -70,7 +80,6 @@ func (t *Theme) S() *Styles {
func (t *Theme) buildStyles() *Styles {
base := lipgloss.NewStyle().
- Background(t.BgBase).
Foreground(t.FgBase)
return &Styles{
Base: base,
@@ -122,6 +131,216 @@ func (t *Theme) buildStyles() *Styles {
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 ",
+ },
+ },
}
}
From 1b178f58ad17e3fd91c54787fd13fbbe56ccdea0 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sun, 1 Jun 2025 22:10:17 +0200
Subject: [PATCH 37/73] sessions dialog
---
.../tui/components/completions/completions.go | 3 +-
internal/tui/components/completions/item.go | 47 +++--
internal/tui/components/core/helpers.go | 17 +-
internal/tui/components/core/list/list.go | 65 +++++--
.../components/dialogs/commands/arguments.go | 13 +-
.../tui/components/dialogs/commands/keys.go | 10 +-
.../tui/components/dialogs/sessions/keys.go | 56 ++++++
.../components/dialogs/sessions/sessions.go | 172 ++++++++++++++++++
internal/tui/layout/layout.go | 5 +
internal/tui/styles/crush.go | 35 ++--
internal/tui/styles/theme.go | 67 +++++--
internal/tui/tui.go | 18 +-
12 files changed, 436 insertions(+), 72 deletions(-)
create mode 100644 internal/tui/components/dialogs/sessions/keys.go
create mode 100644 internal/tui/components/dialogs/sessions/sessions.go
diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go
index 7733aac48ccc27c4b43a61880873009d77ff0a66..9ad622a9e5fca17665f93d0d1c7c6bcef4575329 100644
--- a/internal/tui/components/completions/completions.go
+++ b/internal/tui/components/completions/completions.go
@@ -133,8 +133,9 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
+ 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
diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go
index 20782645888d232a5253e2070f4e7773978b9ddc..f7a2f628115fb4dee6957cf6b6968b7375b40e7f 100644
--- a/internal/tui/components/completions/item.go
+++ b/internal/tui/components/completions/item.go
@@ -1,13 +1,14 @@
package completions
import (
+ "image/color"
+
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/tui/components/core/list"
"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"
"github.com/rivo/uniseg"
)
@@ -27,16 +28,35 @@ type completionItemCmp struct {
value any
focus bool
matchIndexes []int
+ bgColor color.Color
+}
+
+type completionOptions func(*completionItemCmp)
+
+func WithBackgroundColor(c color.Color) completionOptions {
+ return func(cmp *completionItemCmp) {
+ cmp.bgColor = c
+ }
}
-func NewCompletionItem(text string, value any, matchIndexes ...int) CompletionItem {
- return &completionItemCmp{
- text: text,
- value: value,
- matchIndexes: matchIndexes,
+func WithMatchIndexes(indexes ...int) completionOptions {
+ return func(cmp *completionItemCmp) {
+ cmp.matchIndexes = indexes
}
}
+func NewCompletionItem(text string, value any, opts ...completionOptions) 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
@@ -49,15 +69,18 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
// View implements CommandItem.
func (c *completionItemCmp) View() tea.View {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
- baseStyle := styles.BaseStyle().Background(t.BackgroundSecondary())
- titleStyle := baseStyle.Padding(0, 1).Width(c.width).Foreground(t.Text())
- titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
+ titleStyle := t.S().Text.Padding(0, 1).Width(c.width)
+ titleMatchStyle := t.S().Text.Underline(true)
+ if c.bgColor != nil {
+ titleStyle = titleStyle.Background(c.bgColor)
+ titleMatchStyle = titleMatchStyle.Background(c.bgColor)
+ }
if c.focus {
- titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
- titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
+ titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width)
+ titleMatchStyle = t.S().TextSelected.Underline(true)
}
var truncatedTitle string
diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go
index efc3ac745691f11c0a5769ec7761c5f150f18217..60c9709bf0ae0560e40b5e1994e89ab2f055d22e 100644
--- a/internal/tui/components/core/helpers.go
+++ b/internal/tui/components/core/helpers.go
@@ -9,13 +9,26 @@ import (
"github.com/opencode-ai/opencode/internal/tui/styles"
)
-func Section(title string, width int) string {
+func Section(text string, width int) string {
t := styles.CurrentTheme()
char := "─"
+ length := len(text) + 1
+ remainingWidth := width - length
+ if remainingWidth > 0 {
+ text = text + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth))
+ }
+ return text
+}
+
+func Title(title string, width int) string {
+ t := styles.CurrentTheme()
+ char := "╱"
length := len(title) + 1
remainingWidth := width - length
+ lineStyle := t.S().Base.Foreground(t.Primary)
+ titleStyle := t.S().Base.Foreground(t.Secondary)
if remainingWidth > 0 {
- title = title + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth))
+ title = titleStyle.Render(title) + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
}
return title
}
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 3a7290967a96382fc86e2fc7d1e9aeba6fede8c8..996bd3c11e716f0b79f503736783ba3cb431de2f 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -39,6 +39,7 @@ type ListModel interface {
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
}
@@ -133,11 +134,12 @@ type model struct {
gapSize int // Number of empty lines between items
padding []int // Padding around the list content
- filterable bool // Whether items can be filtered
- filteredItems []util.Model // Filtered items based on current search
- input textinput.Model // Input field for filtering items
- hideFilterInput bool // Whether to hide the filter input field
- currentSearch string // Current search term for filtering
+ 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
+ hideFilterInput bool // Whether to hide the filter input field
+ currentSearch string // Current search term for filtering
}
// listOptions is a function type for configuring list options.
@@ -195,29 +197,39 @@ func WithHideFilterInput(hide bool) listOptions {
}
}
+// WithFilterPlaceholder sets the placeholder text for the filter input field.
+func WithFilterPlaceholder(placeholder string) listOptions {
+ return func(m *model) {
+ m.filterPlaceholder = placeholder
+ }
+}
+
// 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 {
m := &model{
- help: help.New(),
- keyMap: DefaultKeyMap(),
- allItems: []util.Model{},
- filteredItems: []util.Model{},
- renderState: newRenderState(),
- gapSize: DefaultGapSize,
- padding: []int{},
- selectionState: selectionState{selectedIndex: NoSelection},
+ 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...",
}
for _, opt := range opts {
opt(m)
}
if m.filterable && !m.hideFilterInput {
+ t := styles.CurrentTheme()
ti := textinput.New()
- ti.Placeholder = "Type to filter..."
+ ti.Placeholder = m.filterPlaceholder
ti.SetVirtualCursor(false)
ti.Focus()
+ ti.SetStyles(t.S().TextInput)
m.input = ti
// disable j,k movements
@@ -616,7 +628,7 @@ func (m *model) isSectionHeader(index int) bool {
// findFirstSelectableItem finds the first item that is not a section header.
func (m *model) findFirstSelectableItem() int {
- for i := 0; i < len(m.filteredItems); i++ {
+ for i := range m.filteredItems {
if !m.isSectionHeader(i) {
return i
}
@@ -944,7 +956,7 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
m.viewState.width = width
m.ResetView()
if m.filterable && !m.hideFilterInput {
- m.input.SetWidth(m.getItemWidth() - 3)
+ m.input.SetWidth(m.getItemWidth() - 5)
}
return m.setAllItemsSize()
}
@@ -1096,7 +1108,7 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
}
func (c *model) inputStyle() lipgloss.Style {
- return styles.BaseStyle()
+ return styles.BaseStyle().Padding(0, 1, 1, 1)
}
// section represents a group of items under a section header.
@@ -1275,3 +1287,22 @@ func (m *model) SelectedIndex() int {
}
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...)
+}
diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go
index 16963c22dc756483e18e616f1fa44dd425accd7d..730c50c490bbd4fec98653b82e81589f6e2bfeda 100644
--- a/internal/tui/components/dialogs/commands/arguments.go
+++ b/internal/tui/components/dialogs/commands/arguments.go
@@ -220,9 +220,9 @@ func (c *commandArgumentsDialogCmp) View() tea.View {
}
func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- offset := 13 + (1+c.focusIndex)*3
+ row, col := c.Position()
+ offset := row + 3 + (1+c.focusIndex)*3
cursor.Y += offset
- _, col := c.Position()
cursor.X = cursor.X + col + 3
return cursor
}
@@ -237,10 +237,11 @@ func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
BorderForeground(t.TextMuted())
}
-func (q *commandArgumentsDialogCmp) Position() (int, int) {
- row := 10
- col := q.wWidth / 2
- col -= q.width / 2
+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
}
diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go
index 92c2695f5aff71e640aeb41f165237766644210d..4960f086a64f5356f4b5c1643d1b72076b786df2 100644
--- a/internal/tui/components/dialogs/commands/keys.go
+++ b/internal/tui/components/dialogs/commands/keys.go
@@ -14,16 +14,16 @@ type CommandsDialogKeyMap struct {
func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
return CommandsDialogKeyMap{
Select: key.NewBinding(
- key.WithKeys("enter"),
+ key.WithKeys("enter", "tab", "ctrl+y"),
key.WithHelp("enter", "confirm"),
),
Next: key.NewBinding(
- key.WithKeys("tab", "down"),
- key.WithHelp("tab/↓", "next"),
+ key.WithKeys("down", "ctrl+n"),
+ key.WithHelp("↓", "next item"),
),
Previous: key.NewBinding(
- key.WithKeys("shift+tab", "up"),
- key.WithHelp("shift+tab/↑", "previous"),
+ key.WithKeys("up", "ctrl+p"),
+ key.WithHelp("↑", "previous item"),
),
}
}
diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..2ec423d865cbcaa330f87dc652b60556c4886f33
--- /dev/null
+++ b/internal/tui/components/dialogs/sessions/keys.go
@@ -0,0 +1,56 @@
+package sessions
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+ Select key.Binding
+ Next key.Binding
+ Previous 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"),
+ ),
+ }
+}
+
+// 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,
+ key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
+ }
+}
diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go
new file mode 100644
index 0000000000000000000000000000000000000000..0339877dd44b3577ca3e7073e1353ebcb41a6612
--- /dev/null
+++ b/internal/tui/components/dialogs/sessions/sessions.go
@@ -0,0 +1,172 @@
+package sessions
+
+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/opencode-ai/opencode/internal/session"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat"
+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
+ "github.com/opencode-ai/opencode/internal/tui/components/core"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+const id 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))
+ 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)),
+ ),
+ )
+ }
+ 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).PaddingRight(2).AlignHorizontal(lipgloss.Right).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 id
+}
diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go
index 4d01ccc0834f944ebb12f8641ee6f1f2da0ec58d..2213c7288a94a43ba5d2d3769752243ad081c734 100644
--- a/internal/tui/layout/layout.go
+++ b/internal/tui/layout/layout.go
@@ -3,6 +3,7 @@ package layout
import (
"reflect"
+ "github.com/charmbracelet/bubbles/v2/help"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
)
@@ -26,6 +27,10 @@ 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/styles/crush.go b/internal/tui/styles/crush.go
index 348e2b5c961895590130a02017d4aeab8dd98a94..5f2fbd94b7547068bf324024612039e30e8af29e 100644
--- a/internal/tui/styles/crush.go
+++ b/internal/tui/styles/crush.go
@@ -10,30 +10,33 @@ func NewCrushTheme() *Theme {
Name: "crush",
IsDark: true,
- Primary: charmtone.Charple,
- Secondary: charmtone.Dolly,
- Tertiary: charmtone.Bok,
- Accent: charmtone.Zest,
+ Primary: lipgloss.Color(charmtone.Charple.Hex()),
+ Secondary: lipgloss.Color(charmtone.Dolly.Hex()),
+ Tertiary: lipgloss.Color(charmtone.Bok.Hex()),
+ Accent: lipgloss.Color(charmtone.Zest.Hex()),
+
+ PrimaryLight: lipgloss.Color(charmtone.Hazy.Hex()),
// Backgrounds
- BgBase: charmtone.Pepper,
- BgSubtle: charmtone.Charcoal,
- BgOverlay: charmtone.Iron,
+ BgBase: lipgloss.Color(charmtone.Pepper.Hex()),
+ BgSubtle: lipgloss.Color(charmtone.Charcoal.Hex()),
+ BgOverlay: lipgloss.Color(charmtone.Iron.Hex()),
// Foregrounds
- FgBase: charmtone.Ash,
- FgMuted: charmtone.Squid,
- FgSubtle: charmtone.Oyster,
+ FgBase: lipgloss.Color(charmtone.Ash.Hex()),
+ FgMuted: lipgloss.Color(charmtone.Squid.Hex()),
+ FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()),
+ FgSelected: lipgloss.Color(charmtone.Salt.Hex()),
// Borders
- Border: charmtone.Charcoal,
- BorderFocus: charmtone.Charple,
+ Border: lipgloss.Color(charmtone.Charcoal.Hex()),
+ BorderFocus: lipgloss.Color(charmtone.Charple.Hex()),
// Status
- Success: charmtone.Guac,
- Error: charmtone.Sriracha,
- Warning: charmtone.Uni,
- Info: charmtone.Malibu,
+ Success: lipgloss.Color(charmtone.Guac.Hex()),
+ Error: lipgloss.Color(charmtone.Sriracha.Hex()),
+ Warning: lipgloss.Color(charmtone.Uni.Hex()),
+ Info: lipgloss.Color(charmtone.Malibu.Hex()),
// TODO: fix this.
SyntaxBg: lipgloss.Color("#1C1C1F"),
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
index c14bab9c6bda3772749dcd2f13931d630fac2b46..7533b7351773f21868341b66c0adf85104a54ff6 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -4,7 +4,9 @@ import (
"fmt"
"image/color"
+ "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"
@@ -25,13 +27,16 @@ type Theme struct {
Tertiary color.Color
Accent color.Color
+ PrimaryLight color.Color
+
BgBase color.Color
BgSubtle color.Color
BgOverlay color.Color
- FgBase color.Color
- FgMuted color.Color
- FgSubtle color.Color
+ FgBase color.Color
+ FgMuted color.Color
+ FgSubtle color.Color
+ FgSelected color.Color
Border color.Color
BorderFocus color.Color
@@ -51,24 +56,30 @@ type Theme struct {
}
type Styles struct {
- Base lipgloss.Style
+ Base lipgloss.Style
+ SelectedBase lipgloss.Style
- Title lipgloss.Style
- Subtitle lipgloss.Style
- Text lipgloss.Style
- Muted lipgloss.Style
- Subtle 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 & Chroma
Markdown ansi.StyleConfig
// Inputs
- TextArea textarea.Styles
+ TextInput textinput.Styles
+ TextArea textarea.Styles
+
+ // Help
+ Help help.Styles
}
func (t *Theme) S() *Styles {
@@ -84,6 +95,8 @@ func (t *Theme) buildStyles() *Styles {
return &Styles{
Base: base,
+ SelectedBase: base.Background(t.Primary),
+
Title: base.
Foreground(t.Accent).
Bold(true),
@@ -92,7 +105,8 @@ func (t *Theme) buildStyles() *Styles {
Foreground(t.Secondary).
Bold(true),
- Text: base,
+ Text: base,
+ TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
Muted: base.Foreground(t.FgMuted),
@@ -106,6 +120,25 @@ func (t *Theme) buildStyles() *Styles {
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,
@@ -341,6 +374,16 @@ func (t *Theme) buildStyles() *Styles {
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),
+ },
}
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 2cb0af4c681f232daea4f36978db5f4c9e18f885..47b17014b56cd18ea61597b52d26859a8eccaf11 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -1,17 +1,21 @@
package tui
import (
+ "context"
+
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/pubsub"
+ cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/completions"
"github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/sessions"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/page"
"github.com/opencode-ai/opencode/internal/tui/page/chat"
@@ -35,6 +39,9 @@ type appModel struct {
dialog dialogs.DialogCmp
completions completions.Completions
+
+ // Session
+ selectedSessionID string // The ID of the currently selected session
}
// Init initializes the application model and returns initial commands.
@@ -90,6 +97,9 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, statusCmd)
return a, tea.Batch(cmds...)
+ // Session
+ case cmpChat.SessionSelectedMsg:
+ a.selectedSessionID = msg.ID
// Logs
case pubsub.Event[logging.LogMessage]:
// Send to the status component
@@ -170,7 +180,13 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
return util.CmdHandler(dialogs.OpenDialogMsg{
Model: commands.NewCommandDialog(),
})
-
+ case key.Matches(msg, a.keyMap.SwitchSession):
+ return func() tea.Msg {
+ allSessions, _ := a.app.Sessions.List(context.Background())
+ return dialogs.OpenDialogMsg{
+ Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
+ }
+ }
// Page navigation
case key.Matches(msg, a.keyMap.Logs):
return a.moveToPage(page.LogsPage)
From ddbc3ab9b4e76175b9e1d9e908867c63f937cde4 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sun, 1 Jun 2025 22:37:53 +0200
Subject: [PATCH 38/73] handle clear session msg
---
internal/tui/components/chat/sidebar/sidebar.go | 2 ++
internal/tui/tui.go | 2 ++
2 files changed, 4 insertions(+)
diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go
index e8197835a2ef393409940a416380257831f32267..d75b70f596b9c7564846bc5962d31f1d519cbdf4 100644
--- a/internal/tui/components/chat/sidebar/sidebar.go
+++ b/internal/tui/components/chat/sidebar/sidebar.go
@@ -50,6 +50,8 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 47b17014b56cd18ea61597b52d26859a8eccaf11..56e439c686d5f0a07775788abb25be55c182e5a5 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -100,6 +100,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Session
case cmpChat.SessionSelectedMsg:
a.selectedSessionID = msg.ID
+ case cmpChat.SessionClearedMsg:
+ a.selectedSessionID = ""
// Logs
case pubsub.Event[logging.LogMessage]:
// Send to the status component
From 866e0e871dec42691be358c9a4d62993bd9de4d6 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Sun, 1 Jun 2025 22:23:35 -0400
Subject: [PATCH 39/73] fix(logo): possible division by zero
---
internal/tui/components/logo/logo.go | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go
index 06ece3055be1494dcae2693cb2ab5e4fcef036bf..b57dfdb15ce90b783ed20cd874e81caccd889f2c 100644
--- a/internal/tui/components/logo/logo.go
+++ b/internal/tui/components/logo/logo.go
@@ -374,7 +374,10 @@ func blendColors(size int, stops ...color.Color) []color.Color {
segmentSize := segmentSizes[i]
for j := range segmentSize {
- t := float64(j) / float64(segmentSize)
+ var t float64
+ if segmentSize > 1 {
+ t = float64(j) / float64(segmentSize-1)
+ }
c := c1.BlendHcl(c2, t)
blended = append(blended, c)
}
From 2afa489009e048dd14593222fa8231d14bdf5f1e Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Sun, 1 Jun 2025 22:30:19 -0400
Subject: [PATCH 40/73] fix(logo): un-expose the renderer for the letter S
---
internal/tui/components/logo/logo.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go
index b57dfdb15ce90b783ed20cd874e81caccd889f2c..b4ebde37c7fbde4b9a0e95f7d9ad9a0c75ce1ce2 100644
--- a/internal/tui/components/logo/logo.go
+++ b/internal/tui/components/logo/logo.go
@@ -42,7 +42,7 @@ func Render(version string, compact bool, o Opts) string {
}
// Title.
- crush := renderWord(1, !compact, letterC, letterR, letterU, LetterS, letterH)
+ crush := renderWord(1, !compact, letterC, letterR, letterU, letterS, letterH)
crushWidth := lipgloss.Width(crush)
b := new(strings.Builder)
for r := range strings.SplitSeq(crush, "\n") {
@@ -216,10 +216,10 @@ func letterR(stretch bool) string {
)
}
-// LetterS renders the letter S in a stylized way. It takes an integer that
+// 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 {
+func letterS(stretch bool) string {
// Here's what we're making:
//
// ▄▀▀▀▀
From e5ba0c32bcc83d8a74e1ac57f2ee35003e621510 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Mon, 2 Jun 2025 10:31:01 +0200
Subject: [PATCH 41/73] fix(ui): small ui fixes
---
internal/tui/components/chat/editor/editor.go | 6 +++---
internal/tui/components/core/helpers.go | 2 +-
.../tui/components/dialogs/sessions/sessions.go | 2 +-
internal/tui/styles/crush.go | 2 +-
internal/tui/styles/theme.go | 4 +++-
todos.md | 15 +++++++++++----
6 files changed, 20 insertions(+), 11 deletions(-)
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index b18ec71d8f7812e60931e02605bc3ed7784a76f7..f26ad9fb24b575909f8ce9d5ac165928ca0e93e9 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -358,11 +358,11 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
t := styles.CurrentTheme()
ta := textarea.New()
ta.SetStyles(t.S().TextArea)
- ta.SetPromptFunc(2, func(lineIndex int) string {
+ ta.SetPromptFunc(4, func(lineIndex int) string {
if lineIndex == 0 {
- return "> "
+ return " > "
}
- return t.S().Muted.Render(": ")
+ return t.S().Base.Foreground(t.Blue).Render("::: ")
})
ta.ShowLineNumbers = false
ta.CharLimit = -1
diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go
index 60c9709bf0ae0560e40b5e1994e89ab2f055d22e..994433e3169d808c6e717385caf6dc57d4a383e2 100644
--- a/internal/tui/components/core/helpers.go
+++ b/internal/tui/components/core/helpers.go
@@ -26,7 +26,7 @@ func Title(title string, width int) string {
length := len(title) + 1
remainingWidth := width - length
lineStyle := t.S().Base.Foreground(t.Primary)
- titleStyle := t.S().Base.Foreground(t.Secondary)
+ titleStyle := t.S().Base.Foreground(t.Primary)
if remainingWidth > 0 {
title = titleStyle.Render(title) + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
}
diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go
index 0339877dd44b3577ca3e7073e1353ebcb41a6612..b0921ce47dbb3e3a3e6afafcca7b794b8f07bc05 100644
--- a/internal/tui/components/dialogs/sessions/sessions.go
+++ b/internal/tui/components/dialogs/sessions/sessions.go
@@ -124,7 +124,7 @@ func (s *sessionDialogCmp) View() tea.View {
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).PaddingRight(2).AlignHorizontal(lipgloss.Right).Render(s.help.View(s.keyMap)),
+ 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))
diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go
index 5f2fbd94b7547068bf324024612039e30e8af29e..e25c06aafebcc7ee61262931c9fe54875c847b0d 100644
--- a/internal/tui/styles/crush.go
+++ b/internal/tui/styles/crush.go
@@ -15,7 +15,7 @@ func NewCrushTheme() *Theme {
Tertiary: lipgloss.Color(charmtone.Bok.Hex()),
Accent: lipgloss.Color(charmtone.Zest.Hex()),
- PrimaryLight: lipgloss.Color(charmtone.Hazy.Hex()),
+ Blue: lipgloss.Color(charmtone.Malibu.Hex()),
// Backgrounds
BgBase: lipgloss.Color(charmtone.Pepper.Hex()),
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
index 7533b7351773f21868341b66c0adf85104a54ff6..fe9173027f38b899e0b70f084d0ae01d1c0e98c3 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -27,7 +27,9 @@ type Theme struct {
Tertiary color.Color
Accent color.Color
- PrimaryLight color.Color
+ // Colors
+ Blue color.Color
+ // TODO: add any others needed
BgBase color.Color
BgSubtle color.Color
diff --git a/todos.md b/todos.md
index fd87bfff909fd8d05aa4fc3012656a435eb4c717..635dc0703583fa8ec87883d7b34da04241c1c3ef 100644
--- a/todos.md
+++ b/todos.md
@@ -2,7 +2,14 @@
## Landing page
-- [ ] Implement the logo landing page
-- [ ] Add cwd improved
-- [ ] Implement Active LSPs
-- [ ] Implement Active MCPs
+- [x] Implement the logo landing page
+- [x] Add cwd improved
+- [x] Implement Active LSPs
+- [x] Implement Active MCPs
+
+## Dialogs
+
+- [ ] Move sessions and modal dialog to the commands
+- [x] Sessions dialog
+- [ ] Commands
+- [ ] Models
From f230c316d2ec33c44e53ed6dac10074cce7e25d9 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Tue, 3 Jun 2025 11:41:06 +0200
Subject: [PATCH 42/73] commands,model selector
---
internal/llm/models/models.go | 38 +-
internal/tui/components/core/helpers.go | 7 +-
internal/tui/components/core/list/list.go | 45 ++-
internal/tui/components/dialog/filepicker.go | 11 +-
internal/tui/components/dialog/models.go | 374 ------------------
internal/tui/components/dialog/session.go | 233 -----------
.../components/dialogs/commands/commands.go | 141 +++++--
.../tui/components/dialogs/commands/item.go | 25 +-
.../tui/components/dialogs/commands/keys.go | 18 +-
.../tui/components/dialogs/models/keys.go | 56 +++
.../tui/components/dialogs/models/models.go | 261 ++++++++++++
.../components/dialogs/sessions/sessions.go | 8 +-
internal/tui/keys.go | 20 +-
internal/tui/tui.go | 22 +-
todos.md | 4 +-
15 files changed, 523 insertions(+), 740 deletions(-)
delete mode 100644 internal/tui/components/dialog/models.go
delete mode 100644 internal/tui/components/dialog/session.go
create mode 100644 internal/tui/components/dialogs/models/keys.go
create mode 100644 internal/tui/components/dialogs/models/models.go
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/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go
index 994433e3169d808c6e717385caf6dc57d4a383e2..31869a587ae73133c3c8fbcc50129ab0b0632a9c 100644
--- a/internal/tui/components/core/helpers.go
+++ b/internal/tui/components/core/helpers.go
@@ -12,10 +12,11 @@ import (
func Section(text string, width int) string {
t := styles.CurrentTheme()
char := "─"
- length := len(text) + 1
+ length := lipgloss.Width(text) + 1
remainingWidth := width - length
+ lineStyle := t.S().Base.Foreground(t.Border)
if remainingWidth > 0 {
- text = text + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth))
+ text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
}
return text
}
@@ -23,7 +24,7 @@ func Section(text string, width int) string {
func Title(title string, width int) string {
t := styles.CurrentTheme()
char := "╱"
- length := len(title) + 1
+ length := lipgloss.Width(title) + 1
remainingWidth := width - length
lineStyle := t.S().Base.Foreground(t.Primary)
titleStyle := t.S().Base.Foreground(t.Primary)
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 996bd3c11e716f0b79f503736783ba3cb431de2f..6c7d34777f7622d1f474b5dfe7e4fc3553a1420e 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -133,11 +133,13 @@ type model struct {
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
}
@@ -204,10 +206,26 @@ func WithFilterPlaceholder(placeholder string) listOptions {
}
}
+// 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(),
@@ -218,6 +236,7 @@ func New(opts ...listOptions) ListModel {
padding: []int{},
selectionState: selectionState{selectedIndex: NoSelection},
filterPlaceholder: "Type to filter...",
+ inputStyle: t.S().Base.Padding(0, 1, 1, 1),
}
for _, opt := range opts {
opt(m)
@@ -281,7 +300,7 @@ func (m *model) View() tea.View {
if m.filterable && !m.hideFilterInput {
content = lipgloss.JoinVertical(
lipgloss.Left,
- m.inputStyle().Render(m.input.View()),
+ m.inputStyle.Render(m.input.View()),
content,
)
}
@@ -400,7 +419,7 @@ func (m *model) renderVisibleForward() {
renderer := &forwardRenderer{
model: m,
start: 0,
- cutoff: m.viewState.offset + m.listHeight(),
+ 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,
}
@@ -420,7 +439,7 @@ func (m *model) renderVisibleReverse() {
renderer := &reverseRenderer{
model: m,
start: 0,
- cutoff: m.viewState.offset + m.listHeight(),
+ cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2,
items: m.filteredItems,
realIdx: m.renderState.lastIndex,
}
@@ -567,6 +586,10 @@ func (r *reverseRenderer) renderItemLines(item util.Model) []string {
// 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
}
@@ -580,8 +603,9 @@ func (m *model) selectPreviousItem() tea.Cmd {
}
// If we went past the beginning, stay at the first non-header item
- if m.selectionState.selectedIndex < 0 {
- m.selectionState.selectedIndex = m.findFirstSelectableItem()
+ 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())
@@ -593,6 +617,10 @@ func (m *model) selectPreviousItem() tea.Cmd {
// 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
}
@@ -1008,6 +1036,9 @@ func (m *model) listHeight() int {
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)
}
@@ -1107,10 +1138,6 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
return tea.Batch(cmds...)
}
-func (c *model) inputStyle() lipgloss.Style {
- return styles.BaseStyle().Padding(0, 1, 1, 1)
-}
-
// section represents a group of items under a section header.
type section struct {
header SectionHeader
diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go
index 8edb182701a6294521098550e40ee661e727d919..d6d5e10112ad13cc8b93ebd54731610a6c4b8eea 100644
--- a/internal/tui/components/dialog/filepicker.go
+++ b/internal/tui/components/dialog/filepicker.go
@@ -15,7 +15,6 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"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"
@@ -222,11 +221,11 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.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
- }
+ // 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) {
diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go
deleted file mode 100644
index d14ffbf9241d749095aa9a03a8e00b33489ba4b4..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/models.go
+++ /dev/null
@@ -1,374 +0,0 @@
-package dialog
-
-import (
- "fmt"
- "slices"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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 {
- util.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.KeyPressMsg:
- 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() tea.View {
- 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 tea.NewView(
- 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/session.go b/internal/tui/components/dialog/session.go
deleted file mode 100644
index 15a118d6efae995182c3cefa1a4b81ddaa0e5e28..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/session.go
+++ /dev/null
@@ -1,233 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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 {
- util.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.KeyPressMsg:
- 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() tea.View {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- if len(s.sessions) == 0 {
- return tea.NewView(
- 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 tea.NewView(
- baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- 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/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index 07292ae123a4b220c01fd9e51c9e0754634ca561..d90d64f9c1878b22bfcf1f61aa0535ce1d304bbe 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -1,23 +1,29 @@
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/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/completions"
+ "github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/core/list"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"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 (
commandsDialogID dialogs.DialogID = "commands"
- defaultWidth int = 60
+ defaultWidth int = 70
+)
+
+const (
+ SystemCommands int = iota
+ UserCommands
)
// Command represents a command that can be executed
@@ -38,11 +44,18 @@ type commandDialogCmp struct {
wWidth int // Width of the terminal window
wHeight int // Height of the terminal window
- commandList list.ListModel
- commands []Command
- keyMap CommandsDialogKeyMap
+ commandList list.ListModel
+ keyMap CommandsDialogKeyMap
+ help help.Model
+ commandType int // SystemCommands or UserCommands
+ userCommands []Command // User-defined commands
}
+type (
+ SwitchSessionsMsg struct{}
+ SwitchModelMsg struct{}
+)
+
func NewCommandDialog() CommandsDialog {
listKeyMap := list.DefaultKeyMap()
keyMap := DefaultCommandsDialogKeyMap()
@@ -59,11 +72,20 @@ func NewCommandDialog() CommandsDialog {
listKeyMap.DownOneItem = keyMap.Next
listKeyMap.UpOneItem = keyMap.Previous
- commandList := list.New(list.WithFilterable(true), list.WithKeyMap(listKeyMap))
+ 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,
}
}
@@ -72,24 +94,9 @@ func (c *commandDialogCmp) Init() tea.Cmd {
if err != nil {
return util.ReportError(err)
}
- c.commands = commands
-
- commandItems := []util.Model{}
- if len(commands) > 0 {
- commandItems = append(commandItems, NewItemSection("Custom Commands"))
- for _, cmd := range commands {
- commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
- }
- }
-
- commandItems = append(commandItems, NewItemSection("Default"))
- for _, cmd := range c.defaultCommands() {
- c.commands = append(c.commands, cmd)
- commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
- }
-
- c.commandList.SetItems(commandItems)
+ c.userCommands = commands
+ c.SetCommandType(c.commandType)
return c.commandList.Init()
}
@@ -112,6 +119,13 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
+ }
default:
u, cmd := c.commandList.Update(msg)
c.commandList = u.(list.ListModel)
@@ -122,8 +136,17 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (c *commandDialogCmp) View() tea.View {
+ t := styles.CurrentTheme()
listView := c.commandList.View()
- v := tea.NewView(c.style().Render(listView.String()))
+ 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)
@@ -131,8 +154,36 @@ func (c *commandDialogCmp) View() tea.View {
return v
}
+func (c *commandDialogCmp) commandTypeRadio() string {
+ t := styles.CurrentTheme()
+ choices := []string{"System", "User"}
+ iconSelected := "◉"
+ iconUnselected := "○"
+ if c.commandType == SystemCommands {
+ return t.S().Text.Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
+ }
+ return t.S().Text.Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
+}
+
func (c *commandDialogCmp) listWidth() int {
- return defaultWidth - 4 // 4 for padding
+ 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 {
+ commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
+ }
+ return c.commandList.SetItems(commandItems)
}
func (c *commandDialogCmp) listHeight() int {
@@ -141,27 +192,25 @@ func (c *commandDialogCmp) listHeight() int {
}
func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- offset := 10 + 1
+ row, col := c.Position()
+ offset := row + 3
cursor.Y += offset
- _, col := c.Position()
cursor.X = cursor.X + col + 2
return cursor
}
func (c *commandDialogCmp) style() lipgloss.Style {
- t := theme.CurrentTheme()
- return styles.BaseStyle().
+ t := styles.CurrentTheme()
+ return t.S().Base.
Width(c.width).
- Padding(0, 1, 1, 1).
Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted())
+ BorderForeground(t.BorderFocus)
}
-func (q *commandDialogCmp) Position() (int, int) {
- row := 10
- col := q.wWidth / 2
- col -= q.width / 2
+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
}
@@ -197,6 +246,26 @@ func (c *commandDialogCmp) defaultCommands() []Command {
}
},
},
+ {
+ ID: "switch_session",
+ Title: "Switch Session",
+ Description: "Switch to a different session",
+ Handler: func(cmd Command) tea.Cmd {
+ return func() tea.Msg {
+ return SwitchSessionsMsg{}
+ }
+ },
+ },
+ {
+ ID: "switch_model",
+ Title: "Switch Model",
+ Description: "Switch to a different model",
+ Handler: func(cmd Command) tea.Cmd {
+ return func() tea.Msg {
+ return SwitchModelMsg{}
+ }
+ },
+ },
}
}
diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go
index 26974d5082046aaa05477f95fddfdeca889c98dc..bc8c11edc2ead5ae52a6c83a678c4df8807e1be5 100644
--- a/internal/tui/components/dialogs/commands/item.go
+++ b/internal/tui/components/dialogs/commands/item.go
@@ -1,15 +1,12 @@
package commands
import (
- "strings"
-
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
+ "github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/core/list"
"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"
)
@@ -19,8 +16,9 @@ type ItemSection interface {
list.SectionHeader
}
type itemSectionModel struct {
- width int
- title string
+ width int
+ title string
+ noPadding bool // No padding for the section header
}
func NewItemSection(title string) ItemSection {
@@ -38,16 +36,11 @@ func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *itemSectionModel) View() tea.View {
- t := theme.CurrentTheme()
- title := ansi.Truncate(m.title, m.width-1, "…")
- style := styles.BaseStyle().Padding(1, 0, 0, 0).Width(m.width).Foreground(t.TextMuted()).Bold(true)
- if len(title) < m.width {
- remainingWidth := m.width - lipgloss.Width(title)
- if remainingWidth > 0 {
- title += " " + strings.Repeat("─", remainingWidth-1)
- }
- }
- return tea.NewView(style.Render(title))
+ 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) {
diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go
index 4960f086a64f5356f4b5c1643d1b72076b786df2..7bfe0fb69675c8e2c04edc78d59ac0dda05415cd 100644
--- a/internal/tui/components/dialogs/commands/keys.go
+++ b/internal/tui/components/dialogs/commands/keys.go
@@ -9,12 +9,13 @@ type CommandsDialogKeyMap struct {
Select key.Binding
Next key.Binding
Previous key.Binding
+ Tab key.Binding
}
func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
return CommandsDialogKeyMap{
Select: key.NewBinding(
- key.WithKeys("enter", "tab", "ctrl+y"),
+ key.WithKeys("enter", "ctrl+y"),
key.WithHelp("enter", "confirm"),
),
Next: key.NewBinding(
@@ -25,6 +26,10 @@ func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
key.WithKeys("up", "ctrl+p"),
key.WithHelp("↑", "previous item"),
),
+ Tab: key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "switch selection"),
+ ),
}
}
@@ -42,9 +47,16 @@ func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding {
// 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.Next,
- k.Previous,
+ key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
}
}
diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..50bec18f2f51fd695582d7cf5f799fffaee8d577
--- /dev/null
+++ b/internal/tui/components/dialogs/models/keys.go
@@ -0,0 +1,56 @@
+package models
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+ Select key.Binding
+ Next key.Binding
+ Previous 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"),
+ ),
+ }
+}
+
+// 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,
+ key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
+ }
+}
diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go
new file mode 100644
index 0000000000000000000000000000000000000000..b2ee4e8bb6fd7631a03c90c46a7bbb2cab8b274c
--- /dev/null
+++ b/internal/tui/components/dialogs/models/models.go
@@ -0,0 +1,261 @@
+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/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/config"
+ "github.com/opencode-ai/opencode/internal/llm/models"
+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
+ "github.com/opencode-ai/opencode/internal/tui/components/core"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+const (
+ ID 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}),
+ )
+ 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 ID
+}
diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go
index b0921ce47dbb3e3a3e6afafcca7b794b8f07bc05..e64de9b2ccdfd974724f9f12bf8745072df01333 100644
--- a/internal/tui/components/dialogs/sessions/sessions.go
+++ b/internal/tui/components/dialogs/sessions/sessions.go
@@ -63,7 +63,13 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
}
}
- sessionsList := list.New(list.WithFilterable(true), list.WithFilterPlaceholder("Enter a session name"), list.WithKeyMap(listKeyMap), list.WithItems(items))
+ 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{
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index bc836d3dbf1f5bd0e88bc02fb4628e1305f9bcd8..af207f8c06bb720eda6047817cebb7bf60551134 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -6,14 +6,13 @@ import (
)
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
+ Logs key.Binding
+ Quit key.Binding
+ Help key.Binding
+ Commands key.Binding
+ FilePicker key.Binding
+ Models key.Binding
+ SwitchTheme key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -33,11 +32,6 @@ func DefaultKeyMap() KeyMap {
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"),
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 56e439c686d5f0a07775788abb25be55c182e5a5..5bd000b470bc9ce31f0ab3e9d2b6c08e49cf8118 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -14,6 +14,7 @@ import (
"github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/models"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/sessions"
"github.com/opencode-ai/opencode/internal/tui/layout"
@@ -116,6 +117,20 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, pageCmd)
}
return a, tea.Batch(cmds...)
+ // 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 commands.SwitchModelMsg:
+ return a, util.CmdHandler(
+ dialogs.OpenDialogMsg{
+ Model: models.NewModelDialogCmp(),
+ },
+ )
case tea.KeyPressMsg:
return a, a.handleKeyPressMsg(msg)
}
@@ -182,13 +197,6 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
return util.CmdHandler(dialogs.OpenDialogMsg{
Model: commands.NewCommandDialog(),
})
- case key.Matches(msg, a.keyMap.SwitchSession):
- return func() tea.Msg {
- allSessions, _ := a.app.Sessions.List(context.Background())
- return dialogs.OpenDialogMsg{
- Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
- }
- }
// Page navigation
case key.Matches(msg, a.keyMap.Logs):
return a.moveToPage(page.LogsPage)
diff --git a/todos.md b/todos.md
index 635dc0703583fa8ec87883d7b34da04241c1c3ef..e7acfc4c3b73d8073a792c744fc452454fd41193 100644
--- a/todos.md
+++ b/todos.md
@@ -9,7 +9,7 @@
## Dialogs
-- [ ] Move sessions and modal dialog to the commands
+- [x] Cleanup Commands
- [x] Sessions dialog
-- [ ] Commands
- [ ] Models
+- [~] Move sessions and model dialog to the commands
From 67529e5d9921a79b4e8b5f4f6e915f1c9bc9be04 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Mon, 2 Jun 2025 11:55:57 -0400
Subject: [PATCH 43/73] fix: re-reference Charmtone colors directly in theme
Ayman fixed a bug upstream in /x/ansi to make setBackgroundColor work as
expected.
---
go.mod | 4 ++--
go.sum | 6 ++++++
internal/tui/styles/crush.go | 37 ++++++++++++++++++------------------
3 files changed, 27 insertions(+), 20 deletions(-)
diff --git a/go.mod b/go.mod
index b43b828f687cb11429c02f91fa7376af9bbe54ca..533a555d86365c81d5c72e5fbc80794aae777f94 100644
--- a/go.mod
+++ b/go.mod
@@ -14,10 +14,10 @@ require (
github.com/catppuccin/go v0.3.0
github.com/charlievieth/fastwalk v1.0.11
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
- github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c
+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174
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.20250516160309-24eee56f89fa
+ github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9
github.com/fsnotify/fsnotify v1.8.0
github.com/go-logfmt/logfmt v0.6.0
diff --git a/go.sum b/go.sum
index 2ab3f666ec32193eec797d86119fc31b30255b75..c07b31b8e58a2ebf70bb2dfc11bd2f4c1f2f4f76 100644
--- a/go.sum
+++ b/go.sum
@@ -74,6 +74,10 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367 h1:X+w3YtXyLG3oguOKXvcDT8jQP856YLQsq6SwTE+gqTk=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
+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/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=
@@ -82,6 +86,8 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
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.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk=
github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+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=
diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go
index e25c06aafebcc7ee61262931c9fe54875c847b0d..17f5b377ce13a4fc1a6fb3ce71ddc1ecad435698 100644
--- a/internal/tui/styles/crush.go
+++ b/internal/tui/styles/crush.go
@@ -10,33 +10,34 @@ func NewCrushTheme() *Theme {
Name: "crush",
IsDark: true,
- Primary: lipgloss.Color(charmtone.Charple.Hex()),
- Secondary: lipgloss.Color(charmtone.Dolly.Hex()),
- Tertiary: lipgloss.Color(charmtone.Bok.Hex()),
- Accent: lipgloss.Color(charmtone.Zest.Hex()),
+ Primary: charmtone.Charple,
+ Secondary: charmtone.Dolly,
+ Tertiary: charmtone.Bok,
+ Accent: charmtone.Zest,
- Blue: lipgloss.Color(charmtone.Malibu.Hex()),
+ Blue: lipgloss.Color(charmtone.Malibu.Hex()),
+ PrimaryLight: charmtone.Hazy,
// Backgrounds
- BgBase: lipgloss.Color(charmtone.Pepper.Hex()),
- BgSubtle: lipgloss.Color(charmtone.Charcoal.Hex()),
- BgOverlay: lipgloss.Color(charmtone.Iron.Hex()),
+ BgBase: charmtone.Pepper,
+ BgSubtle: charmtone.Charcoal,
+ BgOverlay: charmtone.Iron,
// Foregrounds
- FgBase: lipgloss.Color(charmtone.Ash.Hex()),
- FgMuted: lipgloss.Color(charmtone.Squid.Hex()),
- FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()),
- FgSelected: lipgloss.Color(charmtone.Salt.Hex()),
+ FgBase: charmtone.Ash,
+ FgMuted: charmtone.Squid,
+ FgSubtle: charmtone.Oyster,
+ FgSelected: charmtone.Salt,
// Borders
- Border: lipgloss.Color(charmtone.Charcoal.Hex()),
- BorderFocus: lipgloss.Color(charmtone.Charple.Hex()),
+ Border: charmtone.Charcoal,
+ BorderFocus: charmtone.Charple,
// Status
- Success: lipgloss.Color(charmtone.Guac.Hex()),
- Error: lipgloss.Color(charmtone.Sriracha.Hex()),
- Warning: lipgloss.Color(charmtone.Uni.Hex()),
- Info: lipgloss.Color(charmtone.Malibu.Hex()),
+ Success: charmtone.Guac,
+ Error: charmtone.Sriracha,
+ Warning: charmtone.Uni,
+ Info: charmtone.Malibu,
// TODO: fix this.
SyntaxBg: lipgloss.Color("#1C1C1F"),
From d9f7a41818796ec7a3a2b2f8d43cfbfba520cea9 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Wed, 4 Jun 2025 11:51:08 +0200
Subject: [PATCH 44/73] wip focus and changes
---
go.mod | 2 +-
go.sum | 10 +-
internal/tui/components/chat/editor/editor.go | 31 ++++-
internal/tui/components/core/helpers.go | 5 +-
internal/tui/components/core/status/keys.go | 49 ++++++++
internal/tui/components/core/status/status.go | 113 ++++++++++++++++++
.../components/dialogs/commands/commands.go | 4 +-
internal/tui/components/logo/logo.go | 78 +-----------
internal/tui/keys.go | 27 ++---
internal/tui/layout/container.go | 70 ++++++++---
internal/tui/layout/split.go | 30 +++++
internal/tui/page/chat/chat.go | 52 ++++----
internal/tui/page/chat/keys.go | 46 +++++++
internal/tui/styles/crush.go | 9 +-
internal/tui/styles/theme.go | 85 ++++++++++++-
internal/tui/tui.go | 5 +-
todos.md | 5 +
17 files changed, 463 insertions(+), 158 deletions(-)
create mode 100644 internal/tui/components/core/status/keys.go
create mode 100644 internal/tui/components/core/status/status.go
diff --git a/go.mod b/go.mod
index 533a555d86365c81d5c72e5fbc80794aae777f94..560577ca7c5d2eff0384833aecf76e6f327c4102 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/catppuccin/go v0.3.0
github.com/charlievieth/fastwalk v1.0.11
- github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318
+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
diff --git a/go.sum b/go.sum
index c07b31b8e58a2ebf70bb2dfc11bd2f4c1f2f4f76..2c9413322f0bc1043b97f4e238f05cf494b8941a 100644
--- a/go.sum
+++ b/go.sum
@@ -72,10 +72,10 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr
github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c h1:EoW1x1K2EDKYw1D7raqZqWKnwk21IZVpYqLHQVhz1ZU=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250526132317-434f93986a5c/go.mod h1:sXuGtrlVJo43r1fVGBM06E7PPb16oBl8rDRr6YgQOck=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367 h1:X+w3YtXyLG3oguOKXvcDT8jQP856YLQsq6SwTE+gqTk=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154534-5681225ad367/go.mod h1:oOn1YZGZyJHxJfh4sFAna9vDzxJRNuErLETr/lnlB/I=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e h1:+3I/1v7vbN0Vf8Tjm3Q0zdLQqjOM/TjQBvoRDQtoAss=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f h1:vvNB+i59Wp3L6gYcpuhfAdNjr4/e6qM/st3ySWfmZnU=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f/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/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
@@ -84,8 +84,6 @@ github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4C
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.20250516160309-24eee56f89fa h1:JU05TLAB6nOEL46bxHDV/+e8umBX32ODsGbVkc7o7bk=
-github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
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=
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index f26ad9fb24b575909f8ce9d5ac165928ca0e93e9..c8bf567ad4cab7248f936e1e146ccf4174011a59 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -263,8 +263,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *editorCmp) View() tea.View {
t := styles.CurrentTheme()
cursor := m.textarea.Cursor()
- cursor.X = cursor.X + m.x + 1
- cursor.Y = cursor.Y + m.y + 1 // adjust for padding
+ 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(),
@@ -358,11 +360,15 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
t := styles.CurrentTheme()
ta := textarea.New()
ta.SetStyles(t.S().TextArea)
- ta.SetPromptFunc(4, func(lineIndex int) string {
+ ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
if lineIndex == 0 {
return " > "
}
- return t.S().Base.Foreground(t.Blue).Render("::: ")
+ if focused {
+ return t.S().Base.Foreground(t.Blue).Render("::: ")
+ } else {
+ return t.S().Muted.Render("::: ")
+ }
})
ta.ShowLineNumbers = false
ta.CharLimit = -1
@@ -379,6 +385,23 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
return ta
}
+// Blur implements Container.
+func (c *editorCmp) Blur() tea.Cmd {
+ c.textarea.Blur()
+ return nil
+}
+
+// Focus implements Container.
+func (c *editorCmp) Focus() tea.Cmd {
+ logging.Info("Focusing editor textarea")
+ return c.textarea.Focus()
+}
+
+// IsFocused implements Container.
+func (c *editorCmp) IsFocused() bool {
+ return c.textarea.Focused()
+}
+
func NewEditorCmp(app *app.App) util.Model {
ta := CreateTextArea(nil)
return &editorCmp{
diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go
index 31869a587ae73133c3c8fbcc50129ab0b0632a9c..69b538976f9a2428f7eb369fc16c6aec3d9fd94d 100644
--- a/internal/tui/components/core/helpers.go
+++ b/internal/tui/components/core/helpers.go
@@ -26,10 +26,11 @@ func Title(title string, width int) string {
char := "╱"
length := lipgloss.Width(title) + 1
remainingWidth := width - length
- lineStyle := t.S().Base.Foreground(t.Primary)
titleStyle := t.S().Base.Foreground(t.Primary)
if remainingWidth > 0 {
- title = titleStyle.Render(title) + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
+ lines := strings.Repeat(char, remainingWidth)
+ lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary)
+ title = titleStyle.Render(title) + " " + lines
}
return title
}
diff --git a/internal/tui/components/core/status/keys.go b/internal/tui/components/core/status/keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..245f4328bb2ee82fdb29777f1e5b482e3277e198
--- /dev/null
+++ b/internal/tui/components/core/status/keys.go
@@ -0,0 +1,49 @@
+package status
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+ Tab,
+ Commands,
+ 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"),
+ ),
+ 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.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..a85ef26e21be723f0ae3dcf7a69f50a9cff11fa7
--- /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/opencode-ai/opencode/internal/logging"
+ "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/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).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/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index d90d64f9c1878b22bfcf1f61aa0535ce1d304bbe..127b11dcfd8ea8666a59db30346537633a299e9c 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -160,9 +160,9 @@ func (c *commandDialogCmp) commandTypeRadio() string {
iconSelected := "◉"
iconUnselected := "○"
if c.commandType == SystemCommands {
- return t.S().Text.Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
}
- return t.S().Text.Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
+ return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
}
func (c *commandDialogCmp) listWidth() int {
diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go
index b4ebde37c7fbde4b9a0e95f7d9ad9a0c75ce1ce2..0ef19e1dd83259c389715d8cd9bcd88d7777957c 100644
--- a/internal/tui/components/logo/logo.go
+++ b/internal/tui/components/logo/logo.go
@@ -10,8 +10,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/slice"
- "github.com/lucasb-eyer/go-colorful"
- "github.com/rivo/uniseg"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
)
// letterform represents a letterform. It can be stretched horizontally by
@@ -46,7 +45,7 @@ func Render(version string, compact bool, o Opts) string {
crushWidth := lipgloss.Width(crush)
b := new(strings.Builder)
for r := range strings.SplitSeq(crush, "\n") {
- fmt.Fprintln(b, applyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+ fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
}
crush = b.String()
@@ -312,76 +311,3 @@ func stretchLetterformPart(s string, p letterformProps) string {
}
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
}
-
-// 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, lipgloss.NewStyle().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/keys.go b/internal/tui/keys.go
index af207f8c06bb720eda6047817cebb7bf60551134..8fe13c3986f30ada4a8ac9a2661044e913eda6b3 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -6,13 +6,11 @@ import (
)
type KeyMap struct {
- Logs key.Binding
- Quit key.Binding
- Help key.Binding
- Commands key.Binding
- FilePicker key.Binding
- Models key.Binding
- SwitchTheme key.Binding
+ Logs key.Binding
+ Quit key.Binding
+ Help key.Binding
+ Commands key.Binding
+ FilePicker key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -21,7 +19,6 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("ctrl+l"),
key.WithHelp("ctrl+l", "logs"),
),
-
Quit: key.NewBinding(
key.WithKeys("ctrl+c"),
key.WithHelp("ctrl+c", "quit"),
@@ -31,24 +28,14 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("ctrl+_"),
key.WithHelp("ctrl+?", "toggle help"),
),
-
Commands: key.NewBinding(
- key.WithKeys("ctrl+k"),
- key.WithHelp("ctrl+k", "commands"),
+ key.WithKeys("ctrl+p"),
+ key.WithHelp("ctrl+p", "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"),
- ),
}
}
diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go
index aab6566f8a0459c66dbebc7872cb8af6c2ff3654..523540088d7b779b6f1ec0053476b5938ef354af 100644
--- a/internal/tui/layout/container.go
+++ b/internal/tui/layout/container.go
@@ -13,10 +13,12 @@ type Container interface {
Sizeable
Bindings
Positionable
+ Focusable
}
type container struct {
- width int
- height int
+ width int
+ height int
+ isFocused bool
x, y int
@@ -35,14 +37,39 @@ 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.(util.Model)
- 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() tea.View {
@@ -80,7 +107,8 @@ func (c *container) View() tea.View {
contentView := c.content.View()
view := tea.NewView(style.Render(contentView.String()))
- view.SetCursor(contentView.Cursor())
+ cursor := contentView.Cursor()
+ view.SetCursor(cursor)
return view
}
@@ -136,19 +164,31 @@ func (c *container) BindingKeys() []key.Binding {
return []key.Binding{}
}
-type ContainerOption func(*container)
-
-func NewContainer(content util.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/split.go b/internal/tui/layout/split.go
index 6023648a8de14fe9f3a7a13d429d17dfd1f751e9..88ee9051b920cf96ece4942133cda6d959c0af8d 100644
--- a/internal/tui/layout/split.go
+++ b/internal/tui/layout/split.go
@@ -8,6 +8,14 @@ import (
"github.com/opencode-ai/opencode/internal/tui/util"
)
+type LayoutPanel string
+
+const (
+ LeftPanel LayoutPanel = "left"
+ RightPanel LayoutPanel = "right"
+ BottomPanel LayoutPanel = "bottom"
+)
+
type SplitPaneLayout interface {
util.Model
Sizeable
@@ -19,6 +27,8 @@ type SplitPaneLayout interface {
ClearLeftPanel() tea.Cmd
ClearRightPanel() tea.Cmd
ClearBottomPanel() tea.Cmd
+
+ FocusPanel(panel LayoutPanel) tea.Cmd
}
type splitPaneLayout struct {
@@ -279,6 +289,26 @@ func (s *splitPaneLayout) BindingKeys() []key.Binding {
return keys
}
+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.8,
diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go
index d8c1f0eb81d2b86f2898a0ff43a50e5799ad66a9..b62dba2c9d62eb107e5c2eb06bd5c23b5e1bbd23 100644
--- a/internal/tui/page/chat/chat.go
+++ b/internal/tui/page/chat/chat.go
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"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/chat"
@@ -19,32 +20,28 @@ import (
var ChatPage page.PageID = "chat"
+type ChatFocusedMsg struct {
+ Focused bool // True if the chat input is focused, false otherwise
+}
+
type chatPage struct {
app *app.App
layout layout.SplitPaneLayout
session session.Session
-}
-type ChatKeyMap struct {
- NewSession key.Binding
- Cancel key.Binding
-}
+ keyMap KeyMap
-var keyMap = ChatKeyMap{
- NewSession: key.NewBinding(
- key.WithKeys("ctrl+n"),
- key.WithHelp("ctrl+n", "new session"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
+ chatFocused bool
}
func (p *chatPage) Init() tea.Cmd {
- return p.layout.Init()
+ 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) {
@@ -79,13 +76,28 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.session = msg
case tea.KeyPressMsg:
switch {
- case key.Matches(msg, keyMap.NewSession):
+ 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, keyMap.Cancel):
+
+ case key.Matches(msg, p.keyMap.Tab):
+ logging.Info("Tab key pressed, toggling chat focus")
+ 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
@@ -148,11 +160,6 @@ func (p *chatPage) View() tea.View {
return p.layout.View()
}
-func (p *chatPage) BindingKeys() []key.Binding {
- bindings := layout.KeyMapToSlice(keyMap)
- return bindings
-}
-
func NewChatPage(app *app.App) util.Model {
sidebarContainer := layout.NewContainer(
sidebar.NewSidebarCmp(),
@@ -169,5 +176,6 @@ func NewChatPage(app *app.App) util.Model {
layout.WithFixedBottomHeight(5),
layout.WithFixedRightWidth(31),
),
+ keyMap: DefaultKeyMap(),
}
}
diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go
index 5c2cd9a8199252f90f39ea9c09c8e1f285a06855..d8b151dbd9b3a3f0c20db1f16e51f011c25c4e7f 100644
--- a/internal/tui/page/chat/keys.go
+++ b/internal/tui/page/chat/keys.go
@@ -1 +1,47 @@
package chat
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type KeyMap struct {
+ NewSession 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"),
+ ),
+ }
+}
+
+// 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/styles/crush.go b/internal/tui/styles/crush.go
index 17f5b377ce13a4fc1a6fb3ce71ddc1ecad435698..2d9736e0c30485dfa2404bb436ccb7ed1fbe2c63 100644
--- a/internal/tui/styles/crush.go
+++ b/internal/tui/styles/crush.go
@@ -24,10 +24,11 @@ func NewCrushTheme() *Theme {
BgOverlay: charmtone.Iron,
// Foregrounds
- FgBase: charmtone.Ash,
- FgMuted: charmtone.Squid,
- FgSubtle: charmtone.Oyster,
- FgSelected: charmtone.Salt,
+ FgBase: charmtone.Ash,
+ FgMuted: charmtone.Squid,
+ FgHalfMuted: charmtone.Smoke,
+ FgSubtle: charmtone.Oyster,
+ FgSelected: charmtone.Salt,
// Borders
Border: charmtone.Charcoal,
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
index fe9173027f38b899e0b70f084d0ae01d1c0e98c3..4c512acdf0f1fa37ac1da26fc69bf9efbb10eb36 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -3,6 +3,7 @@ package styles
import (
"fmt"
"image/color"
+ "strings"
"github.com/charmbracelet/bubbles/v2/help"
"github.com/charmbracelet/bubbles/v2/textarea"
@@ -10,6 +11,8 @@ import (
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 (
@@ -35,10 +38,11 @@ type Theme struct {
BgSubtle color.Color
BgOverlay color.Color
- FgBase color.Color
- FgMuted color.Color
- FgSubtle color.Color
- FgSelected color.Color
+ FgBase color.Color
+ FgMuted color.Color
+ FgHalfMuted color.Color
+ FgSubtle color.Color
+ FgSelected color.Color
Border color.Color
BorderFocus color.Color
@@ -491,3 +495,76 @@ func Lighten(c color.Color, percent float64) color.Color {
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/tui.go b/internal/tui/tui.go
index 5bd000b470bc9ce31f0ab3e9d2b6c08e49cf8118..f42afd0e9a154e4689644914bebffeefb7329d39 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -12,6 +12,7 @@ import (
cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/completions"
"github.com/opencode-ai/opencode/internal/tui/components/core"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/status"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/models"
@@ -34,7 +35,7 @@ type appModel struct {
pages map[page.PageID]util.Model
loadedPages map[page.PageID]bool
- status core.StatusCmp
+ status status.StatusCmp
app *app.App
@@ -288,7 +289,7 @@ func New(app *app.App) tea.Model {
model := &appModel{
currentPage: startPage,
app: app,
- status: core.NewStatusCmp(app.LSPClients),
+ status: status.NewStatusCmp(),
loadedPages: make(map[page.PageID]bool),
keyMap: DefaultKeyMap(),
diff --git a/todos.md b/todos.md
index e7acfc4c3b73d8073a792c744fc452454fd41193..beb2f903f3c90a1b6a079ca5032a56de4a6e5017 100644
--- a/todos.md
+++ b/todos.md
@@ -13,3 +13,8 @@
- [x] Sessions dialog
- [ ] Models
- [~] Move sessions and model dialog to the commands
+
+## Investigate
+
+- [ ] Events when tool error
+- [ ] Fancy Spinner
From 4a74863d4aeca5b0fb4284427a21eba09b245342 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Wed, 4 Jun 2025 12:45:13 +0200
Subject: [PATCH 45/73] cleanup old components
---
internal/diff/diff.go | 37 +-
internal/highlight/highlight.go | 175 +------
internal/tui/components/anim/anim.go | 18 +-
internal/tui/components/chat/chat.go | 538 ++++++++++++++++----
internal/tui/components/chat/list.go | 486 ------------------
internal/tui/components/chat/sidebar.go | 381 --------------
internal/tui/components/core/status.go | 329 ------------
internal/tui/components/util/simple-list.go | 164 ------
internal/tui/page/chat.go | 176 -------
internal/tui/styles/chroma.go | 79 +++
internal/tui/styles/crush.go | 20 +-
internal/tui/styles/theme.go | 58 ++-
internal/tui/tui.go | 13 +-
todos.md | 6 +
14 files changed, 633 insertions(+), 1847 deletions(-)
delete mode 100644 internal/tui/components/chat/list.go
delete mode 100644 internal/tui/components/chat/sidebar.go
delete mode 100644 internal/tui/components/core/status.go
delete mode 100644 internal/tui/components/util/simple-list.go
delete mode 100644 internal/tui/page/chat.go
create mode 100644 internal/tui/styles/chroma.go
diff --git a/internal/diff/diff.go b/internal/diff/diff.go
index 589d17f232f92f73d28900c1b5bc606ee8a6f822..58545566e9035ed4122e103ed624972fa27ce4f2 100644
--- a/internal/diff/diff.go
+++ b/internal/diff/diff.go
@@ -12,7 +12,7 @@ import (
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/highlight"
- "github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/sergi/go-diff/diffmatchpatch"
)
@@ -329,12 +329,11 @@ func highlightLine(fileName string, line string, bg color.Color) string {
}
// 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
}
@@ -446,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("")
}
@@ -460,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
@@ -485,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
@@ -499,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("")
}
@@ -520,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
@@ -545,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
@@ -559,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/highlight/highlight.go b/internal/highlight/highlight.go
index 98315a152292bd6302dd2e840d450e429abc0ff4..6517357a1b2a789d0f49ab13dbc5a0cc9e92bfed 100644
--- a/internal/highlight/highlight.go
+++ b/internal/highlight/highlight.go
@@ -4,18 +4,15 @@ import (
"bytes"
"fmt"
"image/color"
- "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/opencode-ai/opencode/internal/tui/theme"
+ chromaStyles "github.com/alecthomas/chroma/v2/styles"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
)
func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
- t := theme.CurrentTheme()
-
// Determine the language lexer to use
l := lexers.Match(fileName)
if l == nil {
@@ -32,171 +29,7 @@ func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
f = formatters.Fallback
}
- // Dynamic theme based on current theme values
- syntaxThemeXml := fmt.Sprintf(`
-
-`,
- 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)
+ style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
// Modify the style to use the provided background
s, err := style.Builder().Transform(
@@ -207,7 +40,7 @@ func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
},
).Build()
if err != nil {
- s = styles.Fallback
+ s = chromaStyles.Fallback
}
// Tokenize and format
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index fa6dabaccb1b4375dcb253c566537840a13b056a..46c4156b02d148f51e8ff03afd7341354de1ad44 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -1,6 +1,7 @@
package anim
import (
+ "fmt"
"image/color"
"math/rand"
"strings"
@@ -12,7 +13,6 @@ import (
"github.com/google/uuid"
"github.com/lucasb-eyer/go-colorful"
"github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -240,7 +240,7 @@ func (a *anim) updateChars(chars *[]cyclingChar) {
// View renders the animation.
func (a anim) View() tea.View {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
var b strings.Builder
for i, c := range a.cyclingChars {
@@ -252,8 +252,7 @@ func (a anim) View() tea.View {
}
if len(a.labelChars) > 1 {
- textStyle := styles.BaseStyle().
- Foreground(t.Text())
+ textStyle := t.S().Text
for _, c := range a.labelChars {
b.WriteString(
textStyle.Render(string(c.currentValue)),
@@ -265,10 +264,15 @@ func (a anim) View() tea.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 := theme.CurrentTheme()
- startColor := theme.GetColor(t.Primary())
- endColor := theme.GetColor(t.Secondary())
+ t := styles.CurrentTheme()
+ startColor := GetColor(t.Primary)
+ endColor := GetColor(t.Secondary)
var (
c = make([]color.Color, length)
start, _ = colorful.Hex(startColor)
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index 52c4daeacd2758a82bbfa87a81f3e3f642c1972f..26d5c1c4af70babc614c4709a4e177ef883eb8b8 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -1,17 +1,22 @@
package chat
import (
- "fmt"
- "sort"
+ "context"
+ "time"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/config"
+ "github.com/opencode-ai/opencode/internal/app"
+ "github.com/opencode-ai/opencode/internal/llm/agent"
"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/logo"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
- "github.com/opencode-ai/opencode/internal/version"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
+ "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"
)
type SendMsg struct {
@@ -23,113 +28,470 @@ 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
+}
+
+// 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
+
+ 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()
+ defaultKeymaps.Up.SetEnabled(false)
+ defaultKeymaps.Down.SetEnabled(false)
+ defaultKeymaps.NDown = key.NewBinding(
+ key.WithKeys("ctrl+j"),
+ )
+ defaultKeymaps.NUp = key.NewBinding(
+ key.WithKeys("ctrl+k"),
+ )
+ defaultKeymaps.Home = key.NewBinding(
+ key.WithKeys("ctrl+shift+up"),
+ )
+ defaultKeymaps.End = key.NewBinding(
+ key.WithKeys("ctrl+shift+down"),
+ )
+ return &messageListCmp{
+ app: app,
+ listCmp: list.New(
+ list.WithGapSize(1),
+ list.WithReverse(true),
+ list.WithKeyMap(defaultKeymaps),
+ ),
+ }
+}
+
+// Init initializes the component (no initialization needed).
+func (m *messageListCmp) Init() tea.Cmd {
+ return nil
+}
+
+// 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 dialog.ThemeChangedMsg:
+ m.listCmp.ResetView()
+ 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{}
+ return m, m.listCmp.SetItems([]util.Model{})
+
+ 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...)
+ }
+}
-func header() string {
- return lipgloss.JoinVertical(
- lipgloss.Top,
- logoBlock(),
- repo(),
- "",
- cwd(),
+// 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...)
+}
+
+// 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
}
-func lspsConfigured() string {
- cfg := config.Get()
- title := "LSP Configuration"
+// 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()
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
+ // 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...)
+}
- lsps := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Render(title)
+// 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)
- // Get LSP names and sort them for consistent ordering
- var lspNames []string
- for name := range cfg.LSP {
- lspNames = append(lspNames, name)
+ // 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
+ }
+ }
}
- sort.Strings(lspNames)
- var lspViews []string
- for _, name := range lspNames {
- lsp := cfg.LSP[name]
- lspName := baseStyle.
- Foreground(t.Text()).
- Render(fmt.Sprintf("• %s", name))
+ return assistantIndex, toolCalls
+}
- cmd := lsp.Command
+// 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
+ }
- lspPath := baseStyle.
- Foreground(t.TextMuted()).
- Render(fmt.Sprintf(" (%s)", cmd))
+ shouldShowMessage := m.shouldShowAssistantMessage(msg)
+ hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
- lspViews = append(lspViews,
- baseStyle.
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- lspName,
- lspPath,
- ),
- ),
+ 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.
- 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...)
+}
+
+// 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)
}
-func logoBlock() string {
- t := theme.CurrentTheme()
- return logo.Render(version.Version, true, logo.Opts{
- FieldColor: t.Secondary(),
- TitleColorA: t.Primary(),
- TitleColorB: t.Secondary(),
- CharmColor: t.Primary(),
- VersionColor: t.Secondary(),
- })
+// 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
}
-func repo() string {
- repo := "https://github.com/opencode-ai/opencode"
- t := theme.CurrentTheme()
+// 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)),
+ ),
+ )
+ }
- return styles.BaseStyle().
- Foreground(t.TextMuted()).
- Render(repo)
+ // 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 cwd() string {
- cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
- 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))
+ }
- return styles.BaseStyle().
- Foreground(t.TextMuted()).
- Render(cwd)
+ // Add cancelled status if applicable
+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
+ options = append(options, messages.WithToolCallCancelled())
+ }
+
+ return options
}
-func initialScreen() string {
- baseStyle := styles.BaseStyle()
+// GetSize returns the current width and height of the component.
+func (m *messageListCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
- return baseStyle.Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(),
- "",
- lspsConfigured(),
- ),
- )
+// 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)
}
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
deleted file mode 100644
index 6fe7b96663bf29d495ac5806f5ffc049c1f1a4bd..0000000000000000000000000000000000000000
--- a/internal/tui/components/chat/list.go
+++ /dev/null
@@ -1,486 +0,0 @@
-package chat
-
-import (
- "context"
- "time"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/llm/agent"
- "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/chat/messages"
- "github.com/opencode-ai/opencode/internal/tui/components/core/list"
- "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"
-)
-
-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
-}
-
-// 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
-
- 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()
- defaultKeymaps.Up.SetEnabled(false)
- defaultKeymaps.Down.SetEnabled(false)
- defaultKeymaps.NDown = key.NewBinding(
- key.WithKeys("ctrl+j"),
- )
- defaultKeymaps.NUp = key.NewBinding(
- key.WithKeys("ctrl+k"),
- )
- defaultKeymaps.Home = key.NewBinding(
- key.WithKeys("ctrl+shift+up"),
- )
- defaultKeymaps.End = key.NewBinding(
- key.WithKeys("ctrl+shift+down"),
- )
- return &messageListCmp{
- app: app,
- listCmp: list.New(
- list.WithGapSize(1),
- list.WithReverse(true),
- list.WithKeyMap(defaultKeymaps),
- ),
- }
-}
-
-// Init initializes the component (no initialization needed).
-func (m *messageListCmp) Init() tea.Cmd {
- return nil
-}
-
-// 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 dialog.ThemeChangedMsg:
- m.listCmp.ResetView()
- 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{}
- return m, m.listCmp.SetItems([]util.Model{})
-
- 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 {
- if len(m.listCmp.Items()) == 0 {
- return tea.NewView(initialScreen())
- }
- 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...)
-}
-
-// 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
- }
- }
-
- // 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...)
-}
-
-// 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
-}
-
-// 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 options
-}
-
-// 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)
-}
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
deleted file mode 100644
index 5d631364a7402f05e2233d28d244c86a0398a3e7..0000000000000000000000000000000000000000
--- a/internal/tui/components/chat/sidebar.go
+++ /dev/null
@@ -1,381 +0,0 @@
-package chat
-
-import (
- "context"
- "fmt"
- "sort"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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"
- "github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-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() tea.View {
- baseStyle := styles.BaseStyle()
-
- return tea.NewView(
- baseStyle.
- Width(m.width).
- PaddingLeft(4).
- PaddingRight(2).
- Height(m.height - 1).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(),
- " ",
- m.sessionSection(),
- " ",
- lspsConfigured(),
- " ",
- 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 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) util.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/core/status.go b/internal/tui/components/core/status.go
deleted file mode 100644
index 648db2a23fa7b5930b5a5b2dadd9c8c398ca202e..0000000000000000000000000000000000000000
--- a/internal/tui/components/core/status.go
+++ /dev/null
@@ -1,329 +0,0 @@
-package core
-
-import (
- "fmt"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/config"
- "github.com/opencode-ai/opencode/internal/llm/models"
- "github.com/opencode-ai/opencode/internal/logging"
- "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 {
- util.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
-
- // Handle sesson messages
- 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
- }
- }
-
- // 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
-}
-
-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() tea.View {
- 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 tea.NewView(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/util/simple-list.go b/internal/tui/components/util/simple-list.go
deleted file mode 100644
index 36df48394e0792d056deab6380d6a1003cbe6b55..0000000000000000000000000000000000000000
--- a/internal/tui/components/util/simple-list.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package utilComponents
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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 SimpleListItem interface {
- Render(selected bool, width int) string
-}
-
-type SimpleList[T SimpleListItem] interface {
- util.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.KeyPressMsg:
- 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() tea.View {
- 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 tea.NewView(
- 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 tea.NewView(
- 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/page/chat.go b/internal/tui/page/chat.go
deleted file mode 100644
index 684e95df2509af4a3af2eb6b9146f27935a22d8a..0000000000000000000000000000000000000000
--- a/internal/tui/page/chat.go
+++ /dev/null
@@ -1,176 +0,0 @@
-package page
-
-import (
- "context"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "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/chat"
- "github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
- "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
-}
-
-type ChatKeyMap struct {
- NewSession key.Binding
- Cancel key.Binding
-}
-
-var keyMap = ChatKeyMap{
- 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 {
- return p.layout.Init()
-}
-
-func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
- 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.setSidebar()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- p.session = msg
- case tea.KeyPressMsg:
- switch {
- 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
- }
- }
- }
- 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() tea.View {
- return p.layout.View()
-}
-
-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) util.Model {
- messagesContainer := layout.NewContainer(
- chat.NewMessagesListCmp(app),
- layout.WithPadding(1, 1, 0, 1),
- )
- editorContainer := layout.NewContainer(
- editor.NewEditorCmp(app),
- layout.WithBorder(true, false, false, false),
- )
- return &chatPage{
- app: app,
- editor: editorContainer,
- messages: messagesContainer,
- layout: layout.NewSplitPane(
- layout.WithLeftPanel(messagesContainer),
- layout.WithBottomPanel(editorContainer),
- ),
- }
-}
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
index 2d9736e0c30485dfa2404bb436ccb7ed1fbe2c63..618e0cb496664d18a01a82e3ffe46a9dd6ea7fdf 100644
--- a/internal/tui/styles/crush.go
+++ b/internal/tui/styles/crush.go
@@ -1,7 +1,6 @@
package styles
import (
- "github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/exp/charmtone"
)
@@ -14,10 +13,6 @@ func NewCrushTheme() *Theme {
Secondary: charmtone.Dolly,
Tertiary: charmtone.Bok,
Accent: charmtone.Zest,
-
- Blue: lipgloss.Color(charmtone.Malibu.Hex()),
- PrimaryLight: charmtone.Hazy,
-
// Backgrounds
BgBase: charmtone.Pepper,
BgSubtle: charmtone.Charcoal,
@@ -40,10 +35,15 @@ func NewCrushTheme() *Theme {
Warning: charmtone.Uni,
Info: charmtone.Malibu,
- // TODO: fix this.
- SyntaxBg: lipgloss.Color("#1C1C1F"),
- SyntaxKeyword: lipgloss.Color("#FF6DFE"),
- SyntaxString: lipgloss.Color("#E8FE96"),
- SyntaxComment: lipgloss.Color("#6B6F85"),
+ // Colors
+ 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/theme.go b/internal/tui/styles/theme.go
index 4c512acdf0f1fa37ac1da26fc69bf9efbb10eb36..099cef8ef957ee2e45c931bb6323e2630f9f74ce 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -30,10 +30,6 @@ type Theme struct {
Tertiary color.Color
Accent color.Color
- // Colors
- Blue color.Color
- // TODO: add any others needed
-
BgBase color.Color
BgSubtle color.Color
BgOverlay color.Color
@@ -52,15 +48,40 @@ type Theme struct {
Warning color.Color
Info color.Color
- // TODO: add more syntax colors, maybe just use a chroma theme here.
- SyntaxBg color.Color
- SyntaxKeyword color.Color
- SyntaxString color.Color
- SyntaxComment color.Color
+ // Colors
+ // 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
@@ -86,6 +107,9 @@ type Styles struct {
// Help
Help help.Styles
+
+ // Diff
+ Diff Diff
}
func (t *Theme) S() *Styles {
@@ -390,6 +414,22 @@ func (t *Theme) buildStyles() *Styles {
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,
+ },
}
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index f42afd0e9a154e4689644914bebffeefb7329d39..d9d2dc5c728bf775b4c05f7440f32c894e6be0c9 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -11,7 +11,6 @@ import (
"github.com/opencode-ai/opencode/internal/pubsub"
cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/completions"
- "github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/core/status"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
@@ -95,7 +94,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Status Messages
case util.InfoMsg, util.ClearStatusMsg:
s, statusCmd := a.status.Update(msg)
- a.status = s.(core.StatusCmp)
+ a.status = s.(status.StatusCmp)
cmds = append(cmds, statusCmd)
return a, tea.Batch(cmds...)
@@ -108,7 +107,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case pubsub.Event[logging.LogMessage]:
// Send to the status component
s, statusCmd := a.status.Update(msg)
- a.status = s.(core.StatusCmp)
+ a.status = s.(status.StatusCmp)
cmds = append(cmds, statusCmd)
// If the current page is logs, update the logs view
@@ -136,7 +135,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, a.handleKeyPressMsg(msg)
}
s, _ := a.status.Update(msg)
- a.status = s.(core.StatusCmp)
+ a.status = s.(status.StatusCmp)
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(util.Model)
cmds = append(cmds, cmd)
@@ -151,7 +150,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
// Update status bar
s, cmd := a.status.Update(msg)
- a.status = s.(core.StatusCmp)
+ a.status = s.(status.StatusCmp)
cmds = append(cmds, cmd)
// Update the current page
@@ -285,7 +284,7 @@ func (a *appModel) View() tea.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,
app: app,
@@ -294,7 +293,7 @@ func New(app *app.App) tea.Model {
keyMap: DefaultKeyMap(),
pages: map[page.PageID]util.Model{
- page.ChatPage: chat.NewChatPage(app),
+ chat.ChatPage: chat.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
},
diff --git a/todos.md b/todos.md
index beb2f903f3c90a1b6a079ca5032a56de4a6e5017..b6c3853b6f44c70bf20851efba3496c09c1c641f 100644
--- a/todos.md
+++ b/todos.md
@@ -13,8 +13,14 @@
- [x] Sessions dialog
- [ ] Models
- [~] Move sessions and model dialog to the commands
+- [ ] Add sessions shortuct
+- [ ] Add all posible actions to the commands
## Investigate
- [ ] Events when tool error
- [ ] Fancy Spinner
+
+## Messages
+
+- [ ] Fix issue with numbers (padding)
From dfd23784fc26989944085cc2398e661d6a954011 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Wed, 4 Jun 2025 13:12:55 +0200
Subject: [PATCH 46/73] more theme cleanup
---
cspell.json | 2 +-
internal/tui/components/chat/chat.go | 4 -
internal/tui/components/chat/editor/editor.go | 12 +-
.../tui/components/chat/messages/messages.go | 19 +-
.../tui/components/chat/messages/renderer.go | 40 ++-
internal/tui/components/chat/messages/tool.go | 15 +-
.../tui/components/completions/completions.go | 7 +-
internal/tui/components/dialog/filepicker.go | 32 +--
internal/tui/components/dialog/help.go | 203 --------------
internal/tui/components/dialog/init.go | 32 +--
internal/tui/components/dialog/permission.go | 90 +++---
internal/tui/components/dialog/theme.go | 201 --------------
.../components/dialogs/commands/arguments.go | 49 ++--
internal/tui/components/dialogs/quit/quit.go | 24 +-
internal/tui/layout/overlay.go | 169 -----------
internal/tui/page/logs.go | 2 +-
internal/tui/styles/markdown.go | 262 +-----------------
internal/tui/styles/styles.go | 155 -----------
18 files changed, 134 insertions(+), 1184 deletions(-)
delete mode 100644 internal/tui/components/dialog/help.go
delete mode 100644 internal/tui/components/dialog/theme.go
delete mode 100644 internal/tui/layout/overlay.go
delete mode 100644 internal/tui/styles/styles.go
diff --git a/cspell.json b/cspell.json
index c2fdb29fd8f1b777049f2df44c43633a16384245..7a440d8fbdf07a8d8274c707710d1d930dabe787 100644
--- a/cspell.json
+++ b/cspell.json
@@ -1 +1 @@
-{"words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps"],"version":"0.2","language":"en","flagWords":[]}
\ No newline at end of file
+{"words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph"],"version":"0.2","language":"en","flagWords":[]}
\ No newline at end of file
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index 26d5c1c4af70babc614c4709a4e177ef883eb8b8..4abff286babc4c3609371fc084567f7c96d91cd3 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -14,7 +14,6 @@ import (
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
"github.com/opencode-ai/opencode/internal/tui/components/core/list"
- "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"
)
@@ -87,9 +86,6 @@ func (m *messageListCmp) Init() tea.Cmd {
// 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 dialog.ThemeChangedMsg:
- m.listCmp.ResetView()
- return m, nil
case SessionSelectedMsg:
if msg.ID != m.session.ID {
cmd := m.SetSession(msg)
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index c8bf567ad4cab7248f936e1e146ccf4174011a59..e9e565daade4c855c9a50fa7f84cfd0a07d2b016 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -22,7 +22,6 @@ import (
"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"
)
@@ -138,9 +137,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
- case dialog.ThemeChangedMsg:
- m.textarea = CreateTextArea(&m.textarea)
- return m, cmd
case chat.SessionSelectedMsg:
if msg.ID != m.session.ID {
m.session = msg
@@ -300,11 +296,11 @@ func (m *editorCmp) GetSize() (int, int) {
func (m *editorCmp) attachmentsContent() string {
var styledAttachments []string
- t := theme.CurrentTheme()
- attachmentStyles := styles.BaseStyle().
+ t := styles.CurrentTheme()
+ attachmentStyles := t.S().Base.
MarginLeft(1).
- Background(t.TextMuted()).
- Foreground(t.Text())
+ Background(t.FgMuted).
+ Foreground(t.FgBase)
for i, attachment := range m.attachments {
var filename string
if len(attachment.FileName) > 10 {
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index f5420bacf923ee2cae5e18ea90c20d7950f36d3e..647db5595978fc44b44d82e4bcf54fddaaebe3f6 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -16,7 +16,6 @@ import (
"github.com/opencode-ai/opencode/internal/tui/components/anim"
"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"
)
@@ -124,7 +123,7 @@ func (m *messageCmp) textWidth() int {
// 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 := theme.CurrentTheme()
+ t := styles.CurrentTheme()
var borderColor color.Color
borderStyle := lipgloss.NormalBorder()
if msg.focused {
@@ -133,17 +132,16 @@ func (msg *messageCmp) style() lipgloss.Style {
switch msg.message.Role {
case message.User:
- borderColor = t.Secondary()
+ borderColor = t.Secondary
case message.Assistant:
- borderColor = t.Primary()
+ borderColor = t.Primary
default:
// Tool call
- borderColor = t.TextMuted()
+ borderColor = t.BgSubtle
}
- return styles.BaseStyle().
+ return t.S().Muted.
BorderLeft(true).
- Foreground(t.TextMuted()).
BorderForeground(borderColor).
BorderStyle(borderStyle)
}
@@ -182,14 +180,13 @@ func (m *messageCmp) renderAssistantMessage() string {
// renderUserMessage renders user messages with file attachments.
// Displays message content and any attached files with appropriate icons.
func (m *messageCmp) renderUserMessage() string {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
parts := []string{
m.markdownContent(),
}
- attachmentStyles := styles.BaseStyle().
+ attachmentStyles := t.S().Text.
MarginLeft(1).
- Background(t.BackgroundSecondary()).
- Foreground(t.Text())
+ Background(t.BgSubtle)
attachments := []string{}
for _, attachment := range m.message.BinaryContent() {
file := filepath.Base(attachment.Path)
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index 3c86c2be1c063f45f36ac2cdd84b600da820856a..ee8d679529505e8e984a0b5fa5c57ccb9d266b57 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -15,7 +15,6 @@ import (
"github.com/opencode-ai/opencode/internal/llm/agent"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
)
// responseContextHeight limits the number of lines displayed in tool output
@@ -107,7 +106,7 @@ func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []
return joinHeaderBody(header, body)
}
-// unmarshalParams safely unmarshals JSON parameters
+// unmarshalParams safely unmarshal JSON parameters
func (br baseRenderer) unmarshalParams(input string, target any) error {
return json.Unmarshal([]byte(input), target)
}
@@ -593,7 +592,7 @@ func joinHeaderBody(header, body string) string {
}
func renderPlainContent(v *toolCallCmp, content string) string {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
content = strings.TrimSpace(content)
lines := strings.Split(content, "\n")
@@ -606,58 +605,55 @@ func renderPlainContent(v *toolCallCmp, content string) string {
if len(ln) > v.textWidth() {
ln = v.fit(ln, v.textWidth())
}
- out = append(out, lipgloss.NewStyle().
+ out = append(out, t.S().Muted.
Width(v.textWidth()).
- Background(t.BackgroundSecondary()).
- Foreground(t.TextMuted()).
+ Background(t.BgSubtle).
Render(ln))
}
if len(lines) > responseContextHeight {
- out = append(out, lipgloss.NewStyle().
- Background(t.BackgroundSecondary()).
- Foreground(t.TextMuted()).
+ out = append(out, t.S().Muted.
+ Background(t.BgSubtle).
Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
}
return strings.Join(out, "\n")
}
func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
truncated := truncateHeight(content, responseContextHeight)
- highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BackgroundSecondary())
+ highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgSubtle)
lines := strings.Split(highlighted, "\n")
if len(strings.Split(content, "\n")) > responseContextHeight {
- lines = append(lines, lipgloss.NewStyle().
- Background(t.BackgroundSecondary()).
- Foreground(t.TextMuted()).
+ lines = append(lines, t.S().Muted.
+ Background(t.BgSubtle).
Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
}
for i, ln := range lines {
- num := lipgloss.NewStyle().
- PaddingLeft(4).PaddingRight(2).
- Background(t.BackgroundSecondary()).
- Foreground(t.TextMuted()).
+ num := t.S().Muted.
+ Background(t.BgSubtle).
+ PaddingLeft(4).
+ PaddingRight(2).
Render(fmt.Sprintf("%d", i+1+offset))
w := v.textWidth() - lipgloss.Width(num)
lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
num,
- lipgloss.NewStyle().
+ t.S().Base.
Width(w).
- Background(t.BackgroundSecondary()).
+ Background(t.BgSubtle).
Render(v.fit(ln, w)))
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
func (v *toolCallCmp) renderToolError() string {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
err := strings.ReplaceAll(v.result.Content, "\n", " ")
err = fmt.Sprintf("Error: %s", err)
- return styles.BaseStyle().Foreground(t.Error()).Render(v.fit(err, v.textWidth()))
+ return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
}
func removeWorkingDirPrefix(path string) string {
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index 2f23146a26ba204f895bcac9ee61cb8360f91e2b..33d711f3941af28c233242d4e4f94783358d1031 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -11,7 +11,6 @@ import (
"github.com/opencode-ai/opencode/internal/tui/components/anim"
"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"
)
@@ -216,19 +215,17 @@ func (m *toolCallCmp) renderPending() string {
// 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 := theme.CurrentTheme()
+ t := styles.CurrentTheme()
if m.isNested {
- return styles.BaseStyle().
- Foreground(t.TextMuted())
+ return t.S().Muted
}
borderStyle := lipgloss.NormalBorder()
if m.focused {
borderStyle = lipgloss.DoubleBorder()
}
- return styles.BaseStyle().
+ return t.S().Muted.
BorderLeft(true).
- Foreground(t.TextMuted()).
- BorderForeground(t.TextMuted()).
+ BorderForeground(t.Border).
BorderStyle(borderStyle)
}
@@ -240,8 +237,8 @@ func (m *toolCallCmp) textWidth() int {
// fit truncates content to fit within the specified width with ellipsis
func (m *toolCallCmp) fit(content string, width int) string {
- t := theme.CurrentTheme()
- lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
+ t := styles.CurrentTheme()
+ lineStyle := t.S().Muted.Background(t.BgSubtle)
dots := lineStyle.Render("...")
return ansi.Truncate(content, width, dots)
}
diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go
index 9ad622a9e5fca17665f93d0d1c7c6bcef4575329..392af550050407cf321578fa6906740ee13c1169 100644
--- a/internal/tui/components/completions/completions.go
+++ b/internal/tui/components/completions/completions.go
@@ -6,7 +6,6 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/components/core/list"
"github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -172,11 +171,11 @@ func (c *completionsCmp) View() tea.View {
}
func (c *completionsCmp) style() lipgloss.Style {
- t := theme.CurrentTheme()
- return styles.BaseStyle().
+ t := styles.CurrentTheme()
+ return t.S().Base.
Width(c.width).
Height(c.height).
- Background(t.BackgroundSecondary())
+ Background(t.BgSubtle)
}
func (c *completionsCmp) Open() bool {
diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go
index d6d5e10112ad13cc8b93ebd54731610a6c4b8eea..fdb23f4d3afafcd9a548eb254afd076fe22212d9 100644
--- a/internal/tui/components/dialog/filepicker.go
+++ b/internal/tui/components/dialog/filepicker.go
@@ -19,7 +19,6 @@ import (
"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"
)
@@ -258,7 +257,8 @@ func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
}
func (f *filepickerCmp) View() tea.View {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
+ baseStyle := t.S().Base
const maxVisibleDirs = 20
const maxWidth = 80
@@ -286,12 +286,11 @@ func (f *filepickerCmp) View() tea.View {
for i := startIdx; i < endIdx; i++ {
file := f.dirs[i]
- itemStyle := styles.BaseStyle().Width(adjustedWidth)
+ itemStyle := t.S().Text.Width(adjustedWidth)
if i == f.cursor {
itemStyle = itemStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
+ Background(t.Primary).
Bold(true)
}
filename := file.Name()
@@ -309,20 +308,18 @@ func (f *filepickerCmp) View() tea.View {
// Pad to always show exactly 21 lines
for len(files) < maxVisibleDirs {
- files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
+ files = append(files, baseStyle.Width(adjustedWidth).Render(""))
}
- currentPath := styles.BaseStyle().
+ currentPath := baseStyle.
Height(1).
Width(adjustedWidth).
Render(f.cwd.View())
- viewportstyle := lipgloss.NewStyle().
+ viewportstyle := baseStyle.
Width(f.viewport.Width()).
- Background(t.Background()).
Border(lipgloss.RoundedBorder()).
- BorderForeground(t.TextMuted()).
- BorderBackground(t.Background()).
+ BorderForeground(t.BorderFocus).
Padding(2).
Render(f.viewport.View())
var insertExitText string
@@ -335,17 +332,16 @@ func (f *filepickerCmp) View() tea.View {
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),
+ baseStyle.Width(adjustedWidth).Render(""),
+ baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
+ baseStyle.Width(adjustedWidth).Render(""),
+ t.S().Muted.Width(adjustedWidth).Render(insertExitText),
)
f.cwd.SetValue(f.cwd.Value())
- contentStyle := styles.BaseStyle().Padding(1, 2).
+ contentStyle := baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
+ BorderForeground(t.BorderFocus).
Width(lipgloss.Width(content) + 4)
return tea.NewView(
diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go
deleted file mode 100644
index 549c2a476fa43da14a36c5d942e894d83b6f79ac..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/help.go
+++ /dev/null
@@ -1,203 +0,0 @@
-package dialog
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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 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/v2/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.WithWhitespaceStyle(lipgloss.NewStyle().Background(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() tea.View {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- content := h.render()
- header := baseStyle.
- Bold(true).
- Width(lipgloss.Width(content)).
- Foreground(t.Primary()).
- Render("Keyboard Shortcuts")
-
- return tea.NewView(
- 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 {
- util.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
index d4ef8c523f842ac979969596271b6538efe6af2b..261516b787cca0a9b0142146652b7a41ec7d41c0 100644
--- a/internal/tui/components/dialog/init.go
+++ b/internal/tui/components/dialog/init.go
@@ -6,7 +6,6 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -93,51 +92,45 @@ func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements tea.Model.
func (m InitDialogCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
+ t := styles.CurrentTheme()
+ baseStyle := t.S().Base
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
title := baseStyle.
- Foreground(t.Primary()).
+ Foreground(t.Primary).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Initialize Project")
- explanation := baseStyle.
- Foreground(t.Text()).
+ explanation := t.S().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()).
+ question := t.S().Text.
Width(maxWidth).
Padding(1, 1).
Render("Would you like to initialize this project?")
maxWidth = min(maxWidth, m.width-10)
- yesStyle := baseStyle
- noStyle := baseStyle
+ yesStyle := t.S().Text
+ noStyle := yesStyle
if m.selected == 0 {
yesStyle = yesStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
+ Background(t.Primary).
Bold(true)
noStyle = noStyle.
- Background(t.Background()).
- Foreground(t.Primary())
+ Background(t.BgSubtle)
} else {
noStyle = noStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
+ Background(t.Primary).
Bold(true)
yesStyle = yesStyle.
- Background(t.Background()).
- Foreground(t.Primary())
+ Background(t.BgSubtle)
}
yes := yesStyle.Padding(0, 3).Render("Yes")
@@ -161,8 +154,7 @@ func (m InitDialogCmp) View() string {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
+ BorderForeground(t.BorderFocus).
Width(lipgloss.Width(content) + 4).
Render(content)
}
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index e258dbc24099414309faef78ac8a6aefe204c989..33332e44eaae9de25653f328ad9de457a121c48a 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -13,7 +13,6 @@ import (
"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"
)
@@ -149,28 +148,26 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
}
func (p *permissionDialogCmp) renderButtons() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
+ t := styles.CurrentTheme()
- allowStyle := baseStyle
- allowSessionStyle := baseStyle
- denyStyle := baseStyle
- spacerStyle := baseStyle.Background(t.Background())
+ allowStyle := t.S().Text
+ allowSessionStyle := allowStyle
+ denyStyle := allowStyle
// 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())
+ allowStyle = allowStyle.Background(t.Primary)
+ allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
+ denyStyle = denyStyle.Background(t.BgSubtle)
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())
+ allowStyle = allowStyle.Background(t.BgSubtle)
+ allowSessionStyle = allowSessionStyle.Background(t.Primary)
+ denyStyle = denyStyle.Background(t.BgSubtle)
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())
+ allowStyle = allowStyle.Background(t.BgSubtle)
+ allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
+ denyStyle = denyStyle.Background(t.Primary)
}
allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
@@ -180,33 +177,31 @@ func (p *permissionDialogCmp) renderButtons() string {
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
+ content = strings.Repeat(" ", remainingWidth) + content
}
return content
}
func (p *permissionDialogCmp) renderHeader() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
+ t := styles.CurrentTheme()
+ baseStyle := t.S().Base
- toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
- toolValue := baseStyle.
- Foreground(t.Text()).
+ toolKey := t.S().Muted.Bold(true).Render("Tool")
+ toolValue := t.S().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()).
+ pathKey := t.S().Muted.Bold(true).Render("Path")
+ pathValue := t.S().Text.
Width(p.width - lipgloss.Width(pathKey)).
Render(fmt.Sprintf(": %s", p.permission.Path))
@@ -228,12 +223,11 @@ func (p *permissionDialogCmp) renderHeader() string {
// 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"))
+ headerParts = append(headerParts, t.S().Muted.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()).
+ fileKey := t.S().Muted.Bold(true).Render("File")
+ filePath := t.S().Text.
Width(p.width - lipgloss.Width(fileKey)).
Render(fmt.Sprintf(": %s", params.FilePath))
headerParts = append(headerParts,
@@ -247,9 +241,8 @@ func (p *permissionDialogCmp) renderHeader() string {
case tools.WriteToolName:
params := p.permission.Params.(tools.WritePermissionsParams)
- fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
- filePath := baseStyle.
- Foreground(t.Text()).
+ fileKey := t.S().Muted.Bold(true).Render("File")
+ filePath := t.S().Text.
Width(p.width - lipgloss.Width(fileKey)).
Render(fmt.Sprintf(": %s", params.FilePath))
headerParts = append(headerParts,
@@ -261,15 +254,14 @@ func (p *permissionDialogCmp) renderHeader() string {
baseStyle.Render(strings.Repeat(" ", p.width)),
)
case tools.FetchToolName:
- headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
+ headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
}
- return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
+ return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
}
func (p *permissionDialogCmp) renderBashContent() string {
- baseStyle := styles.BaseStyle()
-
+ baseStyle := styles.CurrentTheme().S().Base
if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
@@ -327,8 +319,7 @@ func (p *permissionDialogCmp) renderWriteContent() string {
}
func (p *permissionDialogCmp) renderFetchContent() string {
- baseStyle := styles.BaseStyle()
-
+ baseStyle := styles.CurrentTheme().S().Base
if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
@@ -349,7 +340,7 @@ func (p *permissionDialogCmp) renderFetchContent() string {
}
func (p *permissionDialogCmp) renderDefaultContent() string {
- baseStyle := styles.BaseStyle()
+ baseStyle := styles.CurrentTheme().S().Base
content := p.permission.Description
@@ -373,21 +364,19 @@ func (p *permissionDialogCmp) renderDefaultContent() string {
}
func (p *permissionDialogCmp) styleViewport() string {
- t := theme.CurrentTheme()
- contentStyle := lipgloss.NewStyle().
- Background(t.Background())
+ t := styles.CurrentTheme()
- return contentStyle.Render(p.contentViewPort.View())
+ return t.S().Base.Render(p.contentViewPort.View())
}
func (p *permissionDialogCmp) render() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
+ t := styles.CurrentTheme()
+ baseStyle := t.S().Base
title := baseStyle.
Bold(true).
Width(p.width - 4).
- Foreground(t.Primary()).
+ Foreground(t.Primary).
Render("Permission Required")
// Render header
headerContent := p.renderHeader()
@@ -428,8 +417,7 @@ func (p *permissionDialogCmp) render() string {
return baseStyle.
Padding(1, 0, 0, 1).
Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
+ BorderForeground(t.BorderFocus).
Width(p.width).
Height(p.height).
Render(
diff --git a/internal/tui/components/dialog/theme.go b/internal/tui/components/dialog/theme.go
deleted file mode 100644
index c5faf6c902d6bdf2f935abb2418d3adb4558def9..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/theme.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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 {
- util.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.KeyPressMsg:
- 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() tea.View {
- currentTheme := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- if len(t.themes) == 0 {
- return tea.NewView(
- 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 tea.NewView(
- 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
index 730c50c490bbd4fec98653b82e81589f6e2bfeda..f08436d299a1f825dd7f525dd5290e7af9a8ed14 100644
--- a/internal/tui/components/dialogs/commands/arguments.go
+++ b/internal/tui/components/dialogs/commands/arguments.go
@@ -11,7 +11,6 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -54,7 +53,7 @@ type commandArgumentsDialogCmp struct {
}
func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
inputs := make([]textinput.Model, len(argNames))
for i, name := range argNames {
@@ -63,15 +62,8 @@ func NewCommandArgumentsDialog(commandID, content string, argNames []string) Com
ti.SetWidth(40)
ti.SetVirtualCursor(false)
ti.Prompt = ""
- ds := ti.Styles()
-
- ds.Blurred.Placeholder = ds.Blurred.Placeholder.Background(t.Background()).Foreground(t.TextMuted())
- ds.Blurred.Prompt = ds.Blurred.Prompt.Background(t.Background()).Foreground(t.TextMuted())
- ds.Blurred.Text = ds.Blurred.Text.Background(t.Background()).Foreground(t.TextMuted())
- ds.Focused.Placeholder = ds.Blurred.Placeholder.Background(t.Background()).Foreground(t.TextMuted())
- ds.Focused.Prompt = ds.Blurred.Prompt.Background(t.Background()).Foreground(t.Text())
- ds.Focused.Text = ds.Blurred.Text.Background(t.Background()).Foreground(t.Text())
- ti.SetStyles(ds)
+
+ ti.SetStyles(t.S().TextInput)
// Only focus the first input initially
if i == 0 {
ti.Focus()
@@ -148,42 +140,36 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View implements CommandArgumentsDialog.
func (c *commandArgumentsDialogCmp) View() tea.View {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
+ t := styles.CurrentTheme()
+ baseStyle := t.S().Base
title := lipgloss.NewStyle().
- Foreground(t.Primary()).
+ Foreground(t.Primary).
Bold(true).
Padding(0, 1).
- Background(t.Background()).
Render("Command Arguments")
- explanation := lipgloss.NewStyle().
- Foreground(t.Text()).
+ explanation := t.S().Text.
Padding(0, 1).
- Background(t.Background()).
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 := lipgloss.NewStyle().
- Padding(1, 1, 0, 1).
- Background(t.Background())
+ labelStyle := baseStyle.
+ Padding(1, 1, 0, 1)
if i == c.focusIndex {
- labelStyle = labelStyle.Foreground(t.Text()).Bold(true)
+ labelStyle = labelStyle.Foreground(t.FgBase).Bold(true)
} else {
- labelStyle = labelStyle.Foreground(t.TextMuted())
+ labelStyle = labelStyle.Foreground(t.FgMuted)
}
label := labelStyle.Render(c.argNames[i] + ":")
- field := lipgloss.NewStyle().
- Foreground(t.Text()).
+ field := t.S().Text.
Padding(0, 1).
- Background(t.Background()).
Render(input.View())
inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
@@ -205,9 +191,7 @@ func (c *commandArgumentsDialogCmp) View() tea.View {
view := tea.NewView(
baseStyle.Padding(1, 1, 0, 1).
Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Background(t.Background()).
+ BorderForeground(t.BorderFocus).
Width(c.width).
Render(content),
)
@@ -228,13 +212,12 @@ func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
}
func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
- t := theme.CurrentTheme()
- return styles.BaseStyle().
+ t := styles.CurrentTheme()
+ return t.S().Base.
Width(c.width).
Padding(1).
Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted())
+ BorderForeground(t.BorderFocus)
}
func (c *commandArgumentsDialogCmp) Position() (int, int) {
diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go
index 211bac3c88258dae43b791080e6570b58192b5db..987edbd6874c29a58c442a238258a0ea68f90360 100644
--- a/internal/tui/components/dialogs/quit/quit.go
+++ b/internal/tui/components/dialogs/quit/quit.go
@@ -7,7 +7,6 @@ import (
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"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"
)
@@ -69,26 +68,24 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View renders the quit dialog with Yes/No buttons.
func (q *quitDialogCmp) View() tea.View {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- yesStyle := baseStyle
- noStyle := baseStyle
- spacerStyle := baseStyle.Background(t.Background())
+ t := styles.CurrentTheme()
+ baseStyle := t.S().Base
+ yesStyle := t.S().Text
+ noStyle := yesStyle
if q.selectedNo {
- noStyle = noStyle.Background(t.Primary()).Foreground(t.Background())
- yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary())
+ noStyle = noStyle.Background(t.Primary)
+ yesStyle = yesStyle.Background(t.BgSubtle)
} else {
- yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background())
- noStyle = noStyle.Background(t.Background()).Foreground(t.Primary())
+ yesStyle = yesStyle.Background(t.Primary)
+ noStyle = noStyle.Background(t.BgSubtle)
}
yesButton := yesStyle.Padding(0, 1).Render("Yes")
noButton := noStyle.Padding(0, 1).Render("No")
buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
- lipgloss.JoinHorizontal(lipgloss.Center, yesButton, spacerStyle.Render(" "), noButton),
+ lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton),
)
content := baseStyle.Render(
@@ -103,8 +100,7 @@ func (q *quitDialogCmp) View() tea.View {
quitDialogStyle := baseStyle.
Padding(1, 2).
Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted())
+ BorderForeground(t.BorderFocus)
return tea.NewView(
quitDialogStyle.Render(content),
diff --git a/internal/tui/layout/overlay.go b/internal/tui/layout/overlay.go
deleted file mode 100644
index 4c9dd5a9435e7d3d9a58a0b0829526532d76f2e0..0000000000000000000000000000000000000000
--- a/internal/tui/layout/overlay.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package layout
-
-import (
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- 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/v2/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/page/logs.go b/internal/tui/page/logs.go
index 63613d02d9c222e6f998b571779f43ae13e74668..e94fa5d12837a4d823804f8c8617ec42cc3a25ba 100644
--- a/internal/tui/page/logs.go
+++ b/internal/tui/page/logs.go
@@ -43,7 +43,7 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (p *logsPage) View() tea.View {
- style := styles.BaseStyle().Width(p.width).Height(p.height)
+ style := styles.CurrentTheme().S().Base.Width(p.width).Height(p.height)
return tea.NewView(
style.Render(
lipgloss.JoinVertical(lipgloss.Top,
diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go
index df6b91bd8e876b4ae44700e7daeb28484d120bd6..deda517add19a306d41320fdaab0c8895f63919e 100644
--- a/internal/tui/styles/markdown.go
+++ b/internal/tui/styles/markdown.go
@@ -1,12 +1,7 @@
package styles
import (
- "fmt"
- "image/color"
-
"github.com/charmbracelet/glamour/v2"
- "github.com/charmbracelet/glamour/v2/ansi"
- "github.com/opencode-ai/opencode/internal/tui/theme"
)
// Helper functions for style pointers
@@ -16,263 +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{
- Color: stringPtr(colorToString(t.MarkdownText())),
- },
- Margin: uintPtr(defaultMargin),
- },
- BlockQuote: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: stringPtr(colorToString(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(colorToString(t.MarkdownText())),
- },
- },
- },
- Heading: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BlockSuffix: "\n",
- Color: stringPtr(colorToString(t.MarkdownHeading())),
- Bold: boolPtr(true),
- },
- },
- H1: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "# ",
- Color: stringPtr(colorToString(t.MarkdownHeading())),
- Bold: boolPtr(true),
- },
- },
- H2: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "## ",
- Color: stringPtr(colorToString(t.MarkdownHeading())),
- Bold: boolPtr(true),
- },
- },
- H3: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "### ",
- Color: stringPtr(colorToString(t.MarkdownHeading())),
- Bold: boolPtr(true),
- },
- },
- H4: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "#### ",
- Color: stringPtr(colorToString(t.MarkdownHeading())),
- Bold: boolPtr(true),
- },
- },
- H5: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "##### ",
- Color: stringPtr(colorToString(t.MarkdownHeading())),
- Bold: boolPtr(true),
- },
- },
- H6: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "###### ",
- Color: stringPtr(colorToString(t.MarkdownHeading())),
- Bold: boolPtr(true),
- },
- },
- Strikethrough: ansi.StylePrimitive{
- CrossedOut: boolPtr(true),
- Color: stringPtr(colorToString(t.TextMuted())),
- },
- Emph: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownEmph())),
- Italic: boolPtr(true),
- },
- Strong: ansi.StylePrimitive{
- Bold: boolPtr(true),
- Color: stringPtr(colorToString(t.MarkdownStrong())),
- },
- HorizontalRule: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownHorizontalRule())),
- Format: "\n─────────────────────────────────────────\n",
- },
- Item: ansi.StylePrimitive{
- BlockPrefix: "• ",
- Color: stringPtr(colorToString(t.MarkdownListItem())),
- },
- Enumeration: ansi.StylePrimitive{
- BlockPrefix: ". ",
- Color: stringPtr(colorToString(t.MarkdownListEnumeration())),
- },
- Task: ansi.StyleTask{
- StylePrimitive: ansi.StylePrimitive{},
- Ticked: "[✓] ",
- Unticked: "[ ] ",
- },
- Link: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownLink())),
- Underline: boolPtr(true),
- },
- LinkText: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownLinkText())),
- Bold: boolPtr(true),
- },
- Image: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownImage())),
- Underline: boolPtr(true),
- Format: "🖼 {{.text}}",
- },
- ImageText: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownImageText())),
- Format: "{{.text}}",
- },
- Code: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownCode())),
- Prefix: "",
- Suffix: "",
- },
- },
- CodeBlock: ansi.StyleCodeBlock{
- StyleBlock: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: " ",
- Color: stringPtr(colorToString(t.MarkdownCodeBlock())),
- },
- Margin: uintPtr(defaultMargin),
- },
- Chroma: &ansi.Chroma{
- Text: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownText())),
- },
- Error: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.Error())),
- },
- Comment: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxComment())),
- },
- CommentPreproc: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxKeyword())),
- },
- Keyword: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxKeyword())),
- },
- KeywordReserved: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxKeyword())),
- },
- KeywordNamespace: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxKeyword())),
- },
- KeywordType: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxType())),
- },
- Operator: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxOperator())),
- },
- Punctuation: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxPunctuation())),
- },
- Name: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxVariable())),
- },
- NameBuiltin: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxVariable())),
- },
- NameTag: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxKeyword())),
- },
- NameAttribute: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxFunction())),
- },
- NameClass: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxType())),
- },
- NameConstant: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxVariable())),
- },
- NameDecorator: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxFunction())),
- },
- NameFunction: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxFunction())),
- },
- LiteralNumber: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxNumber())),
- },
- LiteralString: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxString())),
- },
- LiteralStringEscape: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.SyntaxKeyword())),
- },
- GenericDeleted: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.DiffRemoved())),
- },
- GenericEmph: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownEmph())),
- Italic: boolPtr(true),
- },
- GenericInserted: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.DiffAdded())),
- },
- GenericStrong: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownStrong())),
- Bold: boolPtr(true),
- },
- GenericSubheading: ansi.StylePrimitive{
- Color: stringPtr(colorToString(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(colorToString(t.MarkdownLinkText())),
- },
- Text: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownText())),
- },
- Paragraph: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: stringPtr(colorToString(t.MarkdownText())),
- },
- },
- }
-}
-
-func colorToString(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/tui/styles/styles.go b/internal/tui/styles/styles.go
deleted file mode 100644
index a502e411c74c52b6f4521164f18710213ed62b03..0000000000000000000000000000000000000000
--- a/internal/tui/styles/styles.go
+++ /dev/null
@@ -1,155 +0,0 @@
-package styles
-
-import (
- "image/color"
-
- "github.com/charmbracelet/lipgloss/v2"
- "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() color.Color {
- return theme.CurrentTheme().Primary()
-}
-
-// SecondaryColor returns the secondary color from the current theme
-func SecondaryColor() color.Color {
- return theme.CurrentTheme().Secondary()
-}
-
-// AccentColor returns the accent color from the current theme
-func AccentColor() color.Color {
- return theme.CurrentTheme().Accent()
-}
-
-// ErrorColor returns the error color from the current theme
-func ErrorColor() color.Color {
- return theme.CurrentTheme().Error()
-}
-
-// WarningColor returns the warning color from the current theme
-func WarningColor() color.Color {
- return theme.CurrentTheme().Warning()
-}
-
-// SuccessColor returns the success color from the current theme
-func SuccessColor() color.Color {
- return theme.CurrentTheme().Success()
-}
-
-// InfoColor returns the info color from the current theme
-func InfoColor() color.Color {
- return theme.CurrentTheme().Info()
-}
-
-// TextColor returns the text color from the current theme
-func TextColor() color.Color {
- return theme.CurrentTheme().Text()
-}
-
-// TextMutedColor returns the muted text color from the current theme
-func TextMutedColor() color.Color {
- return theme.CurrentTheme().TextMuted()
-}
-
-// TextEmphasizedColor returns the emphasized text color from the current theme
-func TextEmphasizedColor() color.Color {
- return theme.CurrentTheme().TextEmphasized()
-}
-
-// BackgroundColor returns the background color from the current theme
-func BackgroundColor() color.Color {
- return theme.CurrentTheme().Background()
-}
-
-// BackgroundSecondaryColor returns the secondary background color from the current theme
-func BackgroundSecondaryColor() color.Color {
- return theme.CurrentTheme().BackgroundSecondary()
-}
-
-// BackgroundDarkerColor returns the darker background color from the current theme
-func BackgroundDarkerColor() color.Color {
- return theme.CurrentTheme().BackgroundDarker()
-}
-
-// BorderNormalColor returns the normal border color from the current theme
-func BorderNormalColor() color.Color {
- return theme.CurrentTheme().BorderNormal()
-}
-
-// BorderFocusedColor returns the focused border color from the current theme
-func BorderFocusedColor() color.Color {
- return theme.CurrentTheme().BorderFocused()
-}
-
-// BorderDimColor returns the dim border color from the current theme
-func BorderDimColor() color.Color {
- return theme.CurrentTheme().BorderDim()
-}
From e35920969927ef292c2cf73feddf19456dab8feb Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Wed, 4 Jun 2025 13:18:36 +0200
Subject: [PATCH 47/73] remove old theme
---
internal/app/app.go | 20 --
internal/tui/components/dialog/permission.go | 6 +-
internal/tui/components/dialogs/quit/quit.go | 8 +-
internal/tui/components/logs/details.go | 28 +--
internal/tui/components/logs/table.go | 6 +-
internal/tui/theme/catppuccin.go | 172 --------------
internal/tui/theme/dracula.go | 106 ---------
internal/tui/theme/flexoki.go | 204 -----------------
internal/tui/theme/gruvbox.go | 224 -------------------
internal/tui/theme/manager.go | 124 ----------
internal/tui/theme/monokai.go | 195 ----------------
internal/tui/theme/onedark.go | 196 ----------------
internal/tui/theme/opencode.go | 199 ----------------
internal/tui/theme/theme.go | 205 -----------------
internal/tui/theme/theme_test.go | 89 --------
internal/tui/theme/tokyonight.go | 196 ----------------
internal/tui/theme/tron.go | 198 ----------------
17 files changed, 24 insertions(+), 2152 deletions(-)
delete mode 100644 internal/tui/theme/catppuccin.go
delete mode 100644 internal/tui/theme/dracula.go
delete mode 100644 internal/tui/theme/flexoki.go
delete mode 100644 internal/tui/theme/gruvbox.go
delete mode 100644 internal/tui/theme/manager.go
delete mode 100644 internal/tui/theme/monokai.go
delete mode 100644 internal/tui/theme/onedark.go
delete mode 100644 internal/tui/theme/opencode.go
delete mode 100644 internal/tui/theme/theme.go
delete mode 100644 internal/tui/theme/theme_test.go
delete mode 100644 internal/tui/theme/tokyonight.go
delete mode 100644 internal/tui/theme/tron.go
diff --git a/internal/app/app.go b/internal/app/app.go
index abdc1431db585694021b66df6490c3f50e41bd64..9ebb00ea9bfe6823ec6f02bfd1f6111f6ec58adf 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -19,7 +19,6 @@ import (
"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"
)
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/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index 33332e44eaae9de25653f328ad9de457a121c48a..241dcca1ad3bbd52c97d2ba8306cb32a398a003a 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -157,17 +157,17 @@ func (p *permissionDialogCmp) renderButtons() string {
// Style the selected button
switch p.selectedOption {
case 0:
- allowStyle = allowStyle.Background(t.Primary)
+ allowStyle = allowStyle.Background(t.Secondary)
allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
denyStyle = denyStyle.Background(t.BgSubtle)
case 1:
allowStyle = allowStyle.Background(t.BgSubtle)
- allowSessionStyle = allowSessionStyle.Background(t.Primary)
+ allowSessionStyle = allowSessionStyle.Background(t.Secondary)
denyStyle = denyStyle.Background(t.BgSubtle)
case 2:
allowStyle = allowStyle.Background(t.BgSubtle)
allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
- denyStyle = denyStyle.Background(t.Primary)
+ denyStyle = denyStyle.Background(t.Secondary)
}
allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go
index 987edbd6874c29a58c442a238258a0ea68f90360..df0dbf8887bbbb8d512de4dc911448e695ff62d8 100644
--- a/internal/tui/components/dialogs/quit/quit.go
+++ b/internal/tui/components/dialogs/quit/quit.go
@@ -74,15 +74,15 @@ func (q *quitDialogCmp) View() tea.View {
noStyle := yesStyle
if q.selectedNo {
- noStyle = noStyle.Background(t.Primary)
+ noStyle = noStyle.Background(t.Secondary)
yesStyle = yesStyle.Background(t.BgSubtle)
} else {
- yesStyle = yesStyle.Background(t.Primary)
+ yesStyle = yesStyle.Background(t.Secondary)
noStyle = noStyle.Background(t.BgSubtle)
}
- yesButton := yesStyle.Padding(0, 1).Render("Yes")
- noButton := noStyle.Padding(0, 1).Render("No")
+ 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),
diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go
index 9ee743ca680eab852fb30a059983bbc5f5427f2c..09ddef9c0421a73d5c6a491e8897ea4cda673982 100644
--- a/internal/tui/components/logs/details.go
+++ b/internal/tui/components/logs/details.go
@@ -11,7 +11,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -50,10 +50,10 @@ 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())
+ timeStyle := t.S().Muted
levelStyle := getLevelStyle(i.currentLog.Level)
header := lipgloss.JoinHorizontal(
@@ -67,7 +67,7 @@ func (i *detailCmp) updateContent() {
content.WriteString("\n\n")
// Message with styling
- messageStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
+ messageStyle := t.S().Text.Bold(true)
content.WriteString(messageStyle.Render("Message:"))
content.WriteString("\n")
content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
@@ -75,13 +75,13 @@ func (i *detailCmp) updateContent() {
// Attributes section
if len(i.currentLog.Attributes) > 0 {
- attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(t.Text())
+ attrHeaderStyle := t.S().Text.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())
+ keyStyle := t.S().Base.Foreground(t.Primary).Bold(true)
+ valueStyle := t.S().Text
for _, attr := range i.currentLog.Attributes {
attrLine := fmt.Sprintf("%s: %s",
@@ -97,20 +97,20 @@ 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.Info)
case "warn", "warning":
- return style.Foreground(t.Warning())
+ return style.Foreground(t.Warning)
case "error", "err":
- return style.Foreground(t.Error())
+ return style.Foreground(t.Error)
case "debug":
- return style.Foreground(t.Success())
+ return style.Foreground(t.Success)
default:
- return style.Foreground(t.Text())
+ return style.Foreground(t.FgBase)
}
}
diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go
index 791d104bc0e7127136420bcd2519815709f5b79f..b36d2d967c01c2bb8c23d23de7049768fea9cb47 100644
--- a/internal/tui/components/logs/table.go
+++ b/internal/tui/components/logs/table.go
@@ -10,7 +10,7 @@ import (
"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/theme"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -61,9 +61,9 @@ func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (i *tableCmp) View() tea.View {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
defaultStyles := table.DefaultStyles()
- defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary())
+ defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary)
i.table.SetStyles(defaultStyles)
return tea.NewView(i.table.View())
}
diff --git a/internal/tui/theme/catppuccin.go b/internal/tui/theme/catppuccin.go
deleted file mode 100644
index fd4df0657f82538b57a8eabcb73553df9f9ce9e1..0000000000000000000000000000000000000000
--- a/internal/tui/theme/catppuccin.go
+++ /dev/null
@@ -1,172 +0,0 @@
-package theme
-
-import (
- catppuccin "github.com/catppuccin/go"
- "github.com/charmbracelet/lipgloss/v2"
-)
-
-// CatppuccinTheme implements the Theme interface with Catppuccin colors.
-// It provides both dark (Mocha) and light (Latte) variants.
-type CatppuccinTheme struct {
- BaseTheme
-}
-
-// NewCatppuccinMochaTheme creates a new instance of the Catppuccin Mocha theme.
-func NewCatppuccinMochaTheme() *CatppuccinTheme {
- // Get the Catppuccin palette
- mocha := catppuccin.Mocha
-
- theme := &CatppuccinTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(mocha.Blue().Hex)
- theme.SecondaryColor = lipgloss.Color(mocha.Mauve().Hex)
- theme.AccentColor = lipgloss.Color(mocha.Peach().Hex)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(mocha.Red().Hex)
- theme.WarningColor = lipgloss.Color(mocha.Peach().Hex)
- theme.SuccessColor = lipgloss.Color(mocha.Green().Hex)
- theme.InfoColor = lipgloss.Color(mocha.Blue().Hex)
-
- // Text colors
- theme.TextColor = lipgloss.Color(mocha.Text().Hex)
- theme.TextMutedColor = lipgloss.Color(mocha.Subtext0().Hex)
- theme.TextEmphasizedColor = lipgloss.Color(mocha.Lavender().Hex)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color("#212121") // From existing styles
- theme.BackgroundSecondaryColor = lipgloss.Color("#2c2c2c") // From existing styles
- theme.BackgroundDarkerColor = lipgloss.Color("#181818") // From existing styles
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color("#4b4c5c") // From existing styles
- theme.BorderFocusedColor = lipgloss.Color(mocha.Blue().Hex)
- theme.BorderDimColor = lipgloss.Color(mocha.Surface0().Hex)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#478247") // From existing diff.go
- theme.DiffRemovedColor = lipgloss.Color("#7C4444") // From existing diff.go
- theme.DiffContextColor = lipgloss.Color("#a0a0a0") // From existing diff.go
- theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0") // From existing diff.go
- theme.DiffHighlightAddedColor = lipgloss.Color("#DAFADA") // From existing diff.go
- theme.DiffHighlightRemovedColor = lipgloss.Color("#FADADD") // From existing diff.go
- theme.DiffAddedBgColor = lipgloss.Color("#303A30") // From existing diff.go
- theme.DiffRemovedBgColor = lipgloss.Color("#3A3030") // From existing diff.go
- theme.DiffContextBgColor = lipgloss.Color("#212121") // From existing diff.go
- theme.DiffLineNumberColor = lipgloss.Color("#888888") // From existing diff.go
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#293229") // From existing diff.go
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#332929") // From existing diff.go
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(mocha.Text().Hex)
- theme.MarkdownHeadingColor = lipgloss.Color(mocha.Mauve().Hex)
- theme.MarkdownLinkColor = lipgloss.Color(mocha.Sky().Hex)
- theme.MarkdownLinkTextColor = lipgloss.Color(mocha.Pink().Hex)
- theme.MarkdownCodeColor = lipgloss.Color(mocha.Green().Hex)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(mocha.Yellow().Hex)
- theme.MarkdownEmphColor = lipgloss.Color(mocha.Yellow().Hex)
- theme.MarkdownStrongColor = lipgloss.Color(mocha.Peach().Hex)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(mocha.Overlay0().Hex)
- theme.MarkdownListItemColor = lipgloss.Color(mocha.Blue().Hex)
- theme.MarkdownListEnumerationColor = lipgloss.Color(mocha.Sky().Hex)
- theme.MarkdownImageColor = lipgloss.Color(mocha.Sapphire().Hex)
- theme.MarkdownImageTextColor = lipgloss.Color(mocha.Pink().Hex)
- theme.MarkdownCodeBlockColor = lipgloss.Color(mocha.Text().Hex)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(mocha.Overlay1().Hex)
- theme.SyntaxKeywordColor = lipgloss.Color(mocha.Pink().Hex)
- theme.SyntaxFunctionColor = lipgloss.Color(mocha.Green().Hex)
- theme.SyntaxVariableColor = lipgloss.Color(mocha.Sky().Hex)
- theme.SyntaxStringColor = lipgloss.Color(mocha.Yellow().Hex)
- theme.SyntaxNumberColor = lipgloss.Color(mocha.Teal().Hex)
- theme.SyntaxTypeColor = lipgloss.Color(mocha.Sky().Hex)
- theme.SyntaxOperatorColor = lipgloss.Color(mocha.Pink().Hex)
- theme.SyntaxPunctuationColor = lipgloss.Color(mocha.Text().Hex)
-
- return theme
-}
-
-// NewCatppuccinLatteTheme creates a new instance of the Catppuccin Latte theme.
-func NewCatppuccinLatteTheme() *CatppuccinTheme {
- // Get the Catppuccin palette
- latte := catppuccin.Latte
-
- theme := &CatppuccinTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(latte.Blue().Hex)
- theme.SecondaryColor = lipgloss.Color(latte.Mauve().Hex)
- theme.AccentColor = lipgloss.Color(latte.Peach().Hex)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(latte.Red().Hex)
- theme.WarningColor = lipgloss.Color(latte.Peach().Hex)
- theme.SuccessColor = lipgloss.Color(latte.Green().Hex)
- theme.InfoColor = lipgloss.Color(latte.Blue().Hex)
-
- // Text colors
- theme.TextColor = lipgloss.Color(latte.Text().Hex)
- theme.TextMutedColor = lipgloss.Color(latte.Subtext0().Hex)
- theme.TextEmphasizedColor = lipgloss.Color(latte.Lavender().Hex)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color("#EEEEEE") // Light equivalent
- theme.BackgroundSecondaryColor = lipgloss.Color("#E0E0E0") // Light equivalent
- theme.BackgroundDarkerColor = lipgloss.Color("#F5F5F5") // Light equivalent
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color("#BDBDBD") // Light equivalent
- theme.BorderFocusedColor = lipgloss.Color(latte.Blue().Hex)
- theme.BorderDimColor = lipgloss.Color(latte.Surface0().Hex)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#2E7D32") // Light equivalent
- theme.DiffRemovedColor = lipgloss.Color("#C62828") // Light equivalent
- theme.DiffContextColor = lipgloss.Color("#757575") // Light equivalent
- theme.DiffHunkHeaderColor = lipgloss.Color("#757575") // Light equivalent
- theme.DiffHighlightAddedColor = lipgloss.Color("#A5D6A7") // Light equivalent
- theme.DiffHighlightRemovedColor = lipgloss.Color("#EF9A9A") // Light equivalent
- theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9") // Light equivalent
- theme.DiffRemovedBgColor = lipgloss.Color("#FFEBEE") // Light equivalent
- theme.DiffContextBgColor = lipgloss.Color("#F5F5F5") // Light equivalent
- theme.DiffLineNumberColor = lipgloss.Color("#9E9E9E") // Light equivalent
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9") // Light equivalent
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#FFCDD2") // Light equivalent
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(latte.Text().Hex)
- theme.MarkdownHeadingColor = lipgloss.Color(latte.Mauve().Hex)
- theme.MarkdownLinkColor = lipgloss.Color(latte.Sky().Hex)
- theme.MarkdownLinkTextColor = lipgloss.Color(latte.Pink().Hex)
- theme.MarkdownCodeColor = lipgloss.Color(latte.Green().Hex)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(latte.Yellow().Hex)
- theme.MarkdownEmphColor = lipgloss.Color(latte.Yellow().Hex)
- theme.MarkdownStrongColor = lipgloss.Color(latte.Peach().Hex)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(latte.Overlay0().Hex)
- theme.MarkdownListItemColor = lipgloss.Color(latte.Blue().Hex)
- theme.MarkdownListEnumerationColor = lipgloss.Color(latte.Sky().Hex)
- theme.MarkdownImageColor = lipgloss.Color(latte.Sapphire().Hex)
- theme.MarkdownImageTextColor = lipgloss.Color(latte.Pink().Hex)
- theme.MarkdownCodeBlockColor = lipgloss.Color(latte.Text().Hex)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(latte.Overlay1().Hex)
- theme.SyntaxKeywordColor = lipgloss.Color(latte.Pink().Hex)
- theme.SyntaxFunctionColor = lipgloss.Color(latte.Green().Hex)
- theme.SyntaxVariableColor = lipgloss.Color(latte.Sky().Hex)
- theme.SyntaxStringColor = lipgloss.Color(latte.Yellow().Hex)
- theme.SyntaxNumberColor = lipgloss.Color(latte.Teal().Hex)
- theme.SyntaxTypeColor = lipgloss.Color(latte.Sky().Hex)
- theme.SyntaxOperatorColor = lipgloss.Color(latte.Pink().Hex)
- theme.SyntaxPunctuationColor = lipgloss.Color(latte.Text().Hex)
-
- return theme
-}
-
-func init() {
- // Register the Catppuccin themes with the theme manager
- RegisterTheme("catppuccin-mocha", NewCatppuccinMochaTheme())
- RegisterTheme("catppuccin-latte", NewCatppuccinLatteTheme())
-}
diff --git a/internal/tui/theme/dracula.go b/internal/tui/theme/dracula.go
deleted file mode 100644
index 10a1a7216107bff40415e3f56b66f49d6840035f..0000000000000000000000000000000000000000
--- a/internal/tui/theme/dracula.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
-)
-
-// 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"
-
- theme := &DraculaTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(darkPurple)
- theme.SecondaryColor = lipgloss.Color(darkPink)
- theme.AccentColor = lipgloss.Color(darkCyan)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(darkRed)
- theme.WarningColor = lipgloss.Color(darkOrange)
- theme.SuccessColor = lipgloss.Color(darkGreen)
- theme.InfoColor = lipgloss.Color(darkCyan)
-
- // Text colors
- theme.TextColor = lipgloss.Color(darkForeground)
- theme.TextMutedColor = lipgloss.Color(darkComment)
- theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(darkBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#21222c") // Slightly darker than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(darkBorder)
- theme.BorderFocusedColor = lipgloss.Color(darkPurple)
- theme.BorderDimColor = lipgloss.Color(darkSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color(darkGreen)
- theme.DiffRemovedColor = lipgloss.Color(darkRed)
- theme.DiffContextColor = lipgloss.Color(darkComment)
- theme.DiffHunkHeaderColor = lipgloss.Color(darkPurple)
- theme.DiffHighlightAddedColor = lipgloss.Color("#50fa7b")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#ff5555")
- theme.DiffAddedBgColor = lipgloss.Color("#2c3b2c")
- theme.DiffRemovedBgColor = lipgloss.Color("#3b2c2c")
- theme.DiffContextBgColor = lipgloss.Color(darkBackground)
- theme.DiffLineNumberColor = lipgloss.Color(darkComment)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#253025")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#302525")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(darkForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(darkPink)
- theme.MarkdownLinkColor = lipgloss.Color(darkPurple)
- theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
- theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
- theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
- theme.MarkdownListItemColor = lipgloss.Color(darkPurple)
- theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
- theme.MarkdownImageColor = lipgloss.Color(darkPurple)
- theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(darkComment)
- theme.SyntaxKeywordColor = lipgloss.Color(darkPink)
- theme.SyntaxFunctionColor = lipgloss.Color(darkGreen)
- theme.SyntaxVariableColor = lipgloss.Color(darkOrange)
- theme.SyntaxStringColor = lipgloss.Color(darkYellow)
- theme.SyntaxNumberColor = lipgloss.Color(darkPurple)
- theme.SyntaxTypeColor = lipgloss.Color(darkCyan)
- theme.SyntaxOperatorColor = lipgloss.Color(darkPink)
- theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
-
- return theme
-}
-
-func init() {
- // Register the Dracula theme with the theme manager
- RegisterTheme("dracula", NewDraculaTheme())
-}
diff --git a/internal/tui/theme/flexoki.go b/internal/tui/theme/flexoki.go
deleted file mode 100644
index 183cd65d0de26e7a511dde5f18a98caf523a0941..0000000000000000000000000000000000000000
--- a/internal/tui/theme/flexoki.go
+++ /dev/null
@@ -1,204 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
-)
-
-// 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
-}
-
-// NewFlexokiDarkTheme creates a new instance of the Flexoki Dark theme.
-func NewFlexokiDarkTheme() *FlexokiTheme {
- theme := &FlexokiTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(flexokiBlue400)
- theme.SecondaryColor = lipgloss.Color(flexokiPurple400)
- theme.AccentColor = lipgloss.Color(flexokiOrange400)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(flexokiRed400)
- theme.WarningColor = lipgloss.Color(flexokiYellow400)
- theme.SuccessColor = lipgloss.Color(flexokiGreen400)
- theme.InfoColor = lipgloss.Color(flexokiCyan400)
-
- // Text colors
- theme.TextColor = lipgloss.Color(flexokiBase300)
- theme.TextMutedColor = lipgloss.Color(flexokiBase700)
- theme.TextEmphasizedColor = lipgloss.Color(flexokiYellow400)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(flexokiBlack)
- theme.BackgroundSecondaryColor = lipgloss.Color(flexokiBase950)
- theme.BackgroundDarkerColor = lipgloss.Color(flexokiBase900)
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(flexokiBase900)
- theme.BorderFocusedColor = lipgloss.Color(flexokiBlue400)
- theme.BorderDimColor = lipgloss.Color(flexokiBase850)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color(flexokiGreen400)
- theme.DiffRemovedColor = lipgloss.Color(flexokiRed400)
- theme.DiffContextColor = lipgloss.Color(flexokiBase700)
- theme.DiffHunkHeaderColor = lipgloss.Color(flexokiBase700)
- theme.DiffHighlightAddedColor = lipgloss.Color(flexokiGreen400)
- theme.DiffHighlightRemovedColor = lipgloss.Color(flexokiRed400)
- theme.DiffAddedBgColor = lipgloss.Color("#1D2419") // Darker green background
- theme.DiffRemovedBgColor = lipgloss.Color("#241919") // Darker red background
- theme.DiffContextBgColor = lipgloss.Color(flexokiBlack)
- theme.DiffLineNumberColor = lipgloss.Color(flexokiBase700)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#1A2017") // Slightly darker green
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#201717") // Slightly darker red
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(flexokiBase300)
- theme.MarkdownHeadingColor = lipgloss.Color(flexokiYellow400)
- theme.MarkdownLinkColor = lipgloss.Color(flexokiCyan400)
- theme.MarkdownLinkTextColor = lipgloss.Color(flexokiMagenta400)
- theme.MarkdownCodeColor = lipgloss.Color(flexokiGreen400)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(flexokiCyan400)
- theme.MarkdownEmphColor = lipgloss.Color(flexokiYellow400)
- theme.MarkdownStrongColor = lipgloss.Color(flexokiOrange400)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(flexokiBase800)
- theme.MarkdownListItemColor = lipgloss.Color(flexokiBlue400)
- theme.MarkdownListEnumerationColor = lipgloss.Color(flexokiBlue400)
- theme.MarkdownImageColor = lipgloss.Color(flexokiPurple400)
- theme.MarkdownImageTextColor = lipgloss.Color(flexokiMagenta400)
- theme.MarkdownCodeBlockColor = lipgloss.Color(flexokiBase300)
-
- // Syntax highlighting colors (based on Flexoki's mappings)
- theme.SyntaxCommentColor = lipgloss.Color(flexokiBase700) // tx-3
- theme.SyntaxKeywordColor = lipgloss.Color(flexokiGreen400) // gr
- theme.SyntaxFunctionColor = lipgloss.Color(flexokiOrange400) // or
- theme.SyntaxVariableColor = lipgloss.Color(flexokiBlue400) // bl
- theme.SyntaxStringColor = lipgloss.Color(flexokiCyan400) // cy
- theme.SyntaxNumberColor = lipgloss.Color(flexokiPurple400) // pu
- theme.SyntaxTypeColor = lipgloss.Color(flexokiYellow400) // ye
- theme.SyntaxOperatorColor = lipgloss.Color(flexokiBase500) // tx-2
- theme.SyntaxPunctuationColor = lipgloss.Color(flexokiBase500) // tx-2
-
- return theme
-}
-
-// NewFlexokiLightTheme creates a new instance of the Flexoki Light theme.
-func NewFlexokiLightTheme() *FlexokiTheme {
- theme := &FlexokiTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(flexokiBlue600)
- theme.SecondaryColor = lipgloss.Color(flexokiPurple600)
- theme.AccentColor = lipgloss.Color(flexokiOrange600)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(flexokiRed600)
- theme.WarningColor = lipgloss.Color(flexokiYellow600)
- theme.SuccessColor = lipgloss.Color(flexokiGreen600)
- theme.InfoColor = lipgloss.Color(flexokiCyan600)
-
- // Text colors
- theme.TextColor = lipgloss.Color(flexokiBase600)
- theme.TextMutedColor = lipgloss.Color(flexokiBase500)
- theme.TextEmphasizedColor = lipgloss.Color(flexokiYellow600)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(flexokiPaper)
- theme.BackgroundSecondaryColor = lipgloss.Color(flexokiBase50)
- theme.BackgroundDarkerColor = lipgloss.Color(flexokiBase100)
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(flexokiBase100)
- theme.BorderFocusedColor = lipgloss.Color(flexokiBlue600)
- theme.BorderDimColor = lipgloss.Color(flexokiBase150)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color(flexokiGreen600)
- theme.DiffRemovedColor = lipgloss.Color(flexokiRed600)
- theme.DiffContextColor = lipgloss.Color(flexokiBase500)
- theme.DiffHunkHeaderColor = lipgloss.Color(flexokiBase500)
- theme.DiffHighlightAddedColor = lipgloss.Color(flexokiGreen600)
- theme.DiffHighlightRemovedColor = lipgloss.Color(flexokiRed600)
- theme.DiffAddedBgColor = lipgloss.Color("#EFF2E2") // Light green background
- theme.DiffRemovedBgColor = lipgloss.Color("#F2E2E2") // Light red background
- theme.DiffContextBgColor = lipgloss.Color(flexokiPaper)
- theme.DiffLineNumberColor = lipgloss.Color(flexokiBase500)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#E5EBD9") // Light green
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#EBD9D9") // Light red
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(flexokiBase600)
- theme.MarkdownHeadingColor = lipgloss.Color(flexokiYellow600)
- theme.MarkdownLinkColor = lipgloss.Color(flexokiCyan600)
- theme.MarkdownLinkTextColor = lipgloss.Color(flexokiMagenta600)
- theme.MarkdownCodeColor = lipgloss.Color(flexokiGreen600)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(flexokiCyan600)
- theme.MarkdownEmphColor = lipgloss.Color(flexokiYellow600)
- theme.MarkdownStrongColor = lipgloss.Color(flexokiOrange600)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(flexokiBase200)
- theme.MarkdownListItemColor = lipgloss.Color(flexokiBlue600)
- theme.MarkdownListEnumerationColor = lipgloss.Color(flexokiBlue600)
- theme.MarkdownImageColor = lipgloss.Color(flexokiPurple600)
- theme.MarkdownImageTextColor = lipgloss.Color(flexokiMagenta600)
- theme.MarkdownCodeBlockColor = lipgloss.Color(flexokiBase600)
-
- // Syntax highlighting colors (based on Flexoki's mappings)
- theme.SyntaxCommentColor = lipgloss.Color(flexokiBase300) // tx-3
- theme.SyntaxKeywordColor = lipgloss.Color(flexokiGreen600) // gr
- theme.SyntaxFunctionColor = lipgloss.Color(flexokiOrange600) // or
- theme.SyntaxVariableColor = lipgloss.Color(flexokiBlue600) // bl
- theme.SyntaxStringColor = lipgloss.Color(flexokiCyan600) // cy
- theme.SyntaxNumberColor = lipgloss.Color(flexokiPurple600) // pu
- theme.SyntaxTypeColor = lipgloss.Color(flexokiYellow600) // ye
- theme.SyntaxOperatorColor = lipgloss.Color(flexokiBase500) // tx-2
- theme.SyntaxPunctuationColor = lipgloss.Color(flexokiBase500) // tx-2
-
- return theme
-}
-
-func init() {
- // Register the Flexoki themes with the theme manager
- RegisterTheme("flexoki-dark", NewFlexokiDarkTheme())
- RegisterTheme("flexoki-light", NewFlexokiLightTheme())
-}
diff --git a/internal/tui/theme/gruvbox.go b/internal/tui/theme/gruvbox.go
deleted file mode 100644
index 0eb79b44da2d8c4d039e0073a2e755a6372f03fa..0000000000000000000000000000000000000000
--- a/internal/tui/theme/gruvbox.go
+++ /dev/null
@@ -1,224 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
-)
-
-// 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.Color(gruvboxDarkBlueBright)
- theme.SecondaryColor = lipgloss.Color(gruvboxDarkPurpleBright)
- theme.AccentColor = lipgloss.Color(gruvboxDarkOrangeBright)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(gruvboxDarkRedBright)
- theme.WarningColor = lipgloss.Color(gruvboxDarkYellowBright)
- theme.SuccessColor = lipgloss.Color(gruvboxDarkGreenBright)
- theme.InfoColor = lipgloss.Color(gruvboxDarkBlueBright)
-
- // Text colors
- theme.TextColor = lipgloss.Color(gruvboxDarkFg1)
- theme.TextMutedColor = lipgloss.Color(gruvboxDarkFg4)
- theme.TextEmphasizedColor = lipgloss.Color(gruvboxDarkYellowBright)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(gruvboxDarkBg0)
- theme.BackgroundSecondaryColor = lipgloss.Color(gruvboxDarkBg1)
- theme.BackgroundDarkerColor = lipgloss.Color(gruvboxDarkBg0Soft)
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(gruvboxDarkBg2)
- theme.BorderFocusedColor = lipgloss.Color(gruvboxDarkBlueBright)
- theme.BorderDimColor = lipgloss.Color(gruvboxDarkBg1)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color(gruvboxDarkGreenBright)
- theme.DiffRemovedColor = lipgloss.Color(gruvboxDarkRedBright)
- theme.DiffContextColor = lipgloss.Color(gruvboxDarkFg4)
- theme.DiffHunkHeaderColor = lipgloss.Color(gruvboxDarkFg3)
- theme.DiffHighlightAddedColor = lipgloss.Color(gruvboxDarkGreenBright)
- theme.DiffHighlightRemovedColor = lipgloss.Color(gruvboxDarkRedBright)
- theme.DiffAddedBgColor = lipgloss.Color("#3C4C3C") // Darker green background
- theme.DiffRemovedBgColor = lipgloss.Color("#4C3C3C") // Darker red background
- theme.DiffContextBgColor = lipgloss.Color(gruvboxDarkBg0)
- theme.DiffLineNumberColor = lipgloss.Color(gruvboxDarkFg4)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#32432F") // Slightly darker green
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#43322F") // Slightly darker red
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(gruvboxDarkFg1)
- theme.MarkdownHeadingColor = lipgloss.Color(gruvboxDarkYellowBright)
- theme.MarkdownLinkColor = lipgloss.Color(gruvboxDarkBlueBright)
- theme.MarkdownLinkTextColor = lipgloss.Color(gruvboxDarkAquaBright)
- theme.MarkdownCodeColor = lipgloss.Color(gruvboxDarkGreenBright)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(gruvboxDarkAquaBright)
- theme.MarkdownEmphColor = lipgloss.Color(gruvboxDarkYellowBright)
- theme.MarkdownStrongColor = lipgloss.Color(gruvboxDarkOrangeBright)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(gruvboxDarkBg3)
- theme.MarkdownListItemColor = lipgloss.Color(gruvboxDarkBlueBright)
- theme.MarkdownListEnumerationColor = lipgloss.Color(gruvboxDarkBlueBright)
- theme.MarkdownImageColor = lipgloss.Color(gruvboxDarkPurpleBright)
- theme.MarkdownImageTextColor = lipgloss.Color(gruvboxDarkAquaBright)
- theme.MarkdownCodeBlockColor = lipgloss.Color(gruvboxDarkFg1)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(gruvboxDarkGray)
- theme.SyntaxKeywordColor = lipgloss.Color(gruvboxDarkRedBright)
- theme.SyntaxFunctionColor = lipgloss.Color(gruvboxDarkGreenBright)
- theme.SyntaxVariableColor = lipgloss.Color(gruvboxDarkBlueBright)
- theme.SyntaxStringColor = lipgloss.Color(gruvboxDarkYellowBright)
- theme.SyntaxNumberColor = lipgloss.Color(gruvboxDarkPurpleBright)
- theme.SyntaxTypeColor = lipgloss.Color(gruvboxDarkYellow)
- theme.SyntaxOperatorColor = lipgloss.Color(gruvboxDarkAquaBright)
- theme.SyntaxPunctuationColor = lipgloss.Color(gruvboxDarkFg1)
-
- return theme
-}
-
-// NewGruvboxLightTheme creates a new instance of the Gruvbox Light theme.
-func NewGruvboxLightTheme() *GruvboxTheme {
- theme := &GruvboxTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(gruvboxLightBlueBright)
- theme.SecondaryColor = lipgloss.Color(gruvboxLightPurpleBright)
- theme.AccentColor = lipgloss.Color(gruvboxLightOrangeBright)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(gruvboxLightRedBright)
- theme.WarningColor = lipgloss.Color(gruvboxLightYellowBright)
- theme.SuccessColor = lipgloss.Color(gruvboxLightGreenBright)
- theme.InfoColor = lipgloss.Color(gruvboxLightBlueBright)
-
- // Text colors
- theme.TextColor = lipgloss.Color(gruvboxLightFg1)
- theme.TextMutedColor = lipgloss.Color(gruvboxLightFg4)
- theme.TextEmphasizedColor = lipgloss.Color(gruvboxLightYellowBright)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(gruvboxLightBg0)
- theme.BackgroundSecondaryColor = lipgloss.Color(gruvboxLightBg1)
- theme.BackgroundDarkerColor = lipgloss.Color(gruvboxLightBg0Soft)
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(gruvboxLightBg2)
- theme.BorderFocusedColor = lipgloss.Color(gruvboxLightBlueBright)
- theme.BorderDimColor = lipgloss.Color(gruvboxLightBg1)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color(gruvboxLightGreenBright)
- theme.DiffRemovedColor = lipgloss.Color(gruvboxLightRedBright)
- theme.DiffContextColor = lipgloss.Color(gruvboxLightFg4)
- theme.DiffHunkHeaderColor = lipgloss.Color(gruvboxLightFg3)
- theme.DiffHighlightAddedColor = lipgloss.Color(gruvboxLightGreenBright)
- theme.DiffHighlightRemovedColor = lipgloss.Color(gruvboxLightRedBright)
- theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9") // Light green background
- theme.DiffRemovedBgColor = lipgloss.Color("#FFEBEE") // Light red background
- theme.DiffContextBgColor = lipgloss.Color(gruvboxLightBg0)
- theme.DiffLineNumberColor = lipgloss.Color(gruvboxLightFg4)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9") // Light green
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#FFCDD2") // Light red
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(gruvboxLightFg1)
- theme.MarkdownHeadingColor = lipgloss.Color(gruvboxLightYellowBright)
- theme.MarkdownLinkColor = lipgloss.Color(gruvboxLightBlueBright)
- theme.MarkdownLinkTextColor = lipgloss.Color(gruvboxLightAquaBright)
- theme.MarkdownCodeColor = lipgloss.Color(gruvboxLightGreenBright)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(gruvboxLightAquaBright)
- theme.MarkdownEmphColor = lipgloss.Color(gruvboxLightYellowBright)
- theme.MarkdownStrongColor = lipgloss.Color(gruvboxLightOrangeBright)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(gruvboxLightBg3)
- theme.MarkdownListItemColor = lipgloss.Color(gruvboxLightBlueBright)
- theme.MarkdownListEnumerationColor = lipgloss.Color(gruvboxLightBlueBright)
- theme.MarkdownImageColor = lipgloss.Color(gruvboxLightPurpleBright)
- theme.MarkdownImageTextColor = lipgloss.Color(gruvboxLightAquaBright)
- theme.MarkdownCodeBlockColor = lipgloss.Color(gruvboxLightFg1)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(gruvboxLightGray)
- theme.SyntaxKeywordColor = lipgloss.Color(gruvboxLightRedBright)
- theme.SyntaxFunctionColor = lipgloss.Color(gruvboxLightGreenBright)
- theme.SyntaxVariableColor = lipgloss.Color(gruvboxLightBlueBright)
- theme.SyntaxStringColor = lipgloss.Color(gruvboxLightYellowBright)
- theme.SyntaxNumberColor = lipgloss.Color(gruvboxLightPurpleBright)
- theme.SyntaxTypeColor = lipgloss.Color(gruvboxLightYellow)
- theme.SyntaxOperatorColor = lipgloss.Color(gruvboxLightAquaBright)
- theme.SyntaxPunctuationColor = lipgloss.Color(gruvboxLightFg1)
-
- return theme
-}
-
-func init() {
- // Register the Gruvbox themes with the theme manager
- RegisterTheme("gruvbox", NewGruvboxTheme())
- RegisterTheme("gruvbox-light", NewGruvboxLightTheme())
-}
diff --git a/internal/tui/theme/manager.go b/internal/tui/theme/manager.go
deleted file mode 100644
index e00c9f0ec9ab83dafda8c7fa97ed5496cd0a7ebb..0000000000000000000000000000000000000000
--- a/internal/tui/theme/manager.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package theme
-
-import (
- "fmt"
- "image/color"
- "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]
-}
-
-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)
-}
-
-// 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 abe342906d156da8128df30599a0056b525e05e2..0000000000000000000000000000000000000000
--- a/internal/tui/theme/monokai.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
-)
-
-// 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"
-
- theme := &MonokaiProTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(darkCyan)
- theme.SecondaryColor = lipgloss.Color(darkPurple)
- theme.AccentColor = lipgloss.Color(darkOrange)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(darkRed)
- theme.WarningColor = lipgloss.Color(darkOrange)
- theme.SuccessColor = lipgloss.Color(darkGreen)
- theme.InfoColor = lipgloss.Color(darkBlue)
-
- // Text colors
- theme.TextColor = lipgloss.Color(darkForeground)
- theme.TextMutedColor = lipgloss.Color(darkComment)
- theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(darkBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#221f22") // Slightly darker than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(darkBorder)
- theme.BorderFocusedColor = lipgloss.Color(darkCyan)
- theme.BorderDimColor = lipgloss.Color(darkSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#a9dc76")
- theme.DiffRemovedColor = lipgloss.Color("#ff6188")
- theme.DiffContextColor = lipgloss.Color("#a0a0a0")
- theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0")
- theme.DiffHighlightAddedColor = lipgloss.Color("#c2e7a9")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#ff8ca6")
- theme.DiffAddedBgColor = lipgloss.Color("#3a4a35")
- theme.DiffRemovedBgColor = lipgloss.Color("#4a3439")
- theme.DiffContextBgColor = lipgloss.Color(darkBackground)
- theme.DiffLineNumberColor = lipgloss.Color("#888888")
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#2d3a28")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#3d2a2e")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(darkForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(darkPurple)
- theme.MarkdownLinkColor = lipgloss.Color(darkCyan)
- theme.MarkdownLinkTextColor = lipgloss.Color(darkBlue)
- theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
- theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
- theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
- theme.MarkdownListItemColor = lipgloss.Color(darkCyan)
- theme.MarkdownListEnumerationColor = lipgloss.Color(darkBlue)
- theme.MarkdownImageColor = lipgloss.Color(darkCyan)
- theme.MarkdownImageTextColor = lipgloss.Color(darkBlue)
- theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(darkComment)
- theme.SyntaxKeywordColor = lipgloss.Color(darkRed)
- theme.SyntaxFunctionColor = lipgloss.Color(darkGreen)
- theme.SyntaxVariableColor = lipgloss.Color(darkForeground)
- theme.SyntaxStringColor = lipgloss.Color(darkYellow)
- theme.SyntaxNumberColor = lipgloss.Color(darkPurple)
- theme.SyntaxTypeColor = lipgloss.Color(darkBlue)
- theme.SyntaxOperatorColor = lipgloss.Color(darkCyan)
- theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
-
- return theme
-}
-
-// NewMonokaiProLightTheme creates a new instance of the Monokai Pro Light theme.
-func NewMonokaiProLightTheme() *MonokaiProTheme {
- // 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.Color(lightCyan)
- theme.SecondaryColor = lipgloss.Color(lightPurple)
- theme.AccentColor = lipgloss.Color(lightOrange)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(lightRed)
- theme.WarningColor = lipgloss.Color(lightOrange)
- theme.SuccessColor = lipgloss.Color(lightGreen)
- theme.InfoColor = lipgloss.Color(lightBlue)
-
- // Text colors
- theme.TextColor = lipgloss.Color(lightForeground)
- theme.TextMutedColor = lipgloss.Color(lightComment)
- theme.TextEmphasizedColor = lipgloss.Color(lightYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(lightBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#ffffff") // Slightly lighter than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(lightBorder)
- theme.BorderFocusedColor = lipgloss.Color(lightCyan)
- theme.BorderDimColor = lipgloss.Color(lightSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#9bca65")
- theme.DiffRemovedColor = lipgloss.Color("#f92672")
- theme.DiffContextColor = lipgloss.Color("#757575")
- theme.DiffHunkHeaderColor = lipgloss.Color("#757575")
- theme.DiffHighlightAddedColor = lipgloss.Color("#c5e0b4")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#ffb3c8")
- theme.DiffAddedBgColor = lipgloss.Color("#e8f5e9")
- theme.DiffRemovedBgColor = lipgloss.Color("#ffebee")
- theme.DiffContextBgColor = lipgloss.Color(lightBackground)
- theme.DiffLineNumberColor = lipgloss.Color("#9e9e9e")
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#c8e6c9")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#ffcdd2")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(lightForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(lightPurple)
- theme.MarkdownLinkColor = lipgloss.Color(lightCyan)
- theme.MarkdownLinkTextColor = lipgloss.Color(lightBlue)
- theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
- theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
- theme.MarkdownStrongColor = lipgloss.Color(lightOrange)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
- theme.MarkdownListItemColor = lipgloss.Color(lightCyan)
- theme.MarkdownListEnumerationColor = lipgloss.Color(lightBlue)
- theme.MarkdownImageColor = lipgloss.Color(lightCyan)
- theme.MarkdownImageTextColor = lipgloss.Color(lightBlue)
- theme.MarkdownCodeBlockColor = lipgloss.Color(lightForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(lightComment)
- theme.SyntaxKeywordColor = lipgloss.Color(lightRed)
- theme.SyntaxFunctionColor = lipgloss.Color(lightGreen)
- theme.SyntaxVariableColor = lipgloss.Color(lightForeground)
- theme.SyntaxStringColor = lipgloss.Color(lightYellow)
- theme.SyntaxNumberColor = lipgloss.Color(lightPurple)
- theme.SyntaxTypeColor = lipgloss.Color(lightBlue)
- theme.SyntaxOperatorColor = lipgloss.Color(lightCyan)
- theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
-
- return theme
-}
-
-func init() {
- // Register the Monokai Pro themes with the theme manager
- RegisterTheme("monokai", NewMonokaiProTheme())
- RegisterTheme("monokai-light", NewMonokaiProLightTheme())
-}
diff --git a/internal/tui/theme/onedark.go b/internal/tui/theme/onedark.go
deleted file mode 100644
index 5c694b2837f0c4a86ab4d5b85705847c739c1f4d..0000000000000000000000000000000000000000
--- a/internal/tui/theme/onedark.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
-)
-
-// 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"
-
- theme := &OneDarkTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(darkBlue)
- theme.SecondaryColor = lipgloss.Color(darkPurple)
- theme.AccentColor = lipgloss.Color(darkOrange)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(darkRed)
- theme.WarningColor = lipgloss.Color(darkOrange)
- theme.SuccessColor = lipgloss.Color(darkGreen)
- theme.InfoColor = lipgloss.Color(darkBlue)
-
- // Text colors
- theme.TextColor = lipgloss.Color(darkForeground)
- theme.TextMutedColor = lipgloss.Color(darkComment)
- theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(darkBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#21252b") // Slightly darker than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(darkBorder)
- theme.BorderFocusedColor = lipgloss.Color(darkBlue)
- theme.BorderDimColor = lipgloss.Color(darkSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#478247")
- theme.DiffRemovedColor = lipgloss.Color("#7C4444")
- theme.DiffContextColor = lipgloss.Color("#a0a0a0")
- theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0")
- theme.DiffHighlightAddedColor = lipgloss.Color("#DAFADA")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#FADADD")
- theme.DiffAddedBgColor = lipgloss.Color("#303A30")
- theme.DiffRemovedBgColor = lipgloss.Color("#3A3030")
- theme.DiffContextBgColor = lipgloss.Color(darkBackground)
- theme.DiffLineNumberColor = lipgloss.Color("#888888")
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#293229")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#332929")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(darkForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(darkPurple)
- theme.MarkdownLinkColor = lipgloss.Color(darkBlue)
- theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
- theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
- theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
- theme.MarkdownListItemColor = lipgloss.Color(darkBlue)
- theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
- theme.MarkdownImageColor = lipgloss.Color(darkBlue)
- theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(darkComment)
- theme.SyntaxKeywordColor = lipgloss.Color(darkPurple)
- theme.SyntaxFunctionColor = lipgloss.Color(darkBlue)
- theme.SyntaxVariableColor = lipgloss.Color(darkRed)
- theme.SyntaxStringColor = lipgloss.Color(darkGreen)
- theme.SyntaxNumberColor = lipgloss.Color(darkOrange)
- theme.SyntaxTypeColor = lipgloss.Color(darkYellow)
- theme.SyntaxOperatorColor = lipgloss.Color(darkCyan)
- theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
-
- return theme
-}
-
-// NewOneLightTheme creates a new instance of the One Light theme.
-func NewOneLightTheme() *OneDarkTheme {
- // 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.Color(lightBlue)
- theme.SecondaryColor = lipgloss.Color(lightPurple)
- theme.AccentColor = lipgloss.Color(lightOrange)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(lightRed)
- theme.WarningColor = lipgloss.Color(lightOrange)
- theme.SuccessColor = lipgloss.Color(lightGreen)
- theme.InfoColor = lipgloss.Color(lightBlue)
-
- // Text colors
- theme.TextColor = lipgloss.Color(lightForeground)
- theme.TextMutedColor = lipgloss.Color(lightComment)
- theme.TextEmphasizedColor = lipgloss.Color(lightYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(lightBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#ffffff") // Slightly lighter than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(lightBorder)
- theme.BorderFocusedColor = lipgloss.Color(lightBlue)
- theme.BorderDimColor = lipgloss.Color(lightSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#2E7D32")
- theme.DiffRemovedColor = lipgloss.Color("#C62828")
- theme.DiffContextColor = lipgloss.Color("#757575")
- theme.DiffHunkHeaderColor = lipgloss.Color("#757575")
- theme.DiffHighlightAddedColor = lipgloss.Color("#A5D6A7")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#EF9A9A")
- theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9")
- theme.DiffRemovedBgColor = lipgloss.Color("#FFEBEE")
- theme.DiffContextBgColor = lipgloss.Color(lightBackground)
- theme.DiffLineNumberColor = lipgloss.Color("#9E9E9E")
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#FFCDD2")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(lightForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(lightPurple)
- theme.MarkdownLinkColor = lipgloss.Color(lightBlue)
- theme.MarkdownLinkTextColor = lipgloss.Color(lightCyan)
- theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
- theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
- theme.MarkdownStrongColor = lipgloss.Color(lightOrange)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
- theme.MarkdownListItemColor = lipgloss.Color(lightBlue)
- theme.MarkdownListEnumerationColor = lipgloss.Color(lightCyan)
- theme.MarkdownImageColor = lipgloss.Color(lightBlue)
- theme.MarkdownImageTextColor = lipgloss.Color(lightCyan)
- theme.MarkdownCodeBlockColor = lipgloss.Color(lightForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(lightComment)
- theme.SyntaxKeywordColor = lipgloss.Color(lightPurple)
- theme.SyntaxFunctionColor = lipgloss.Color(lightBlue)
- theme.SyntaxVariableColor = lipgloss.Color(lightRed)
- theme.SyntaxStringColor = lipgloss.Color(lightGreen)
- theme.SyntaxNumberColor = lipgloss.Color(lightOrange)
- theme.SyntaxTypeColor = lipgloss.Color(lightYellow)
- theme.SyntaxOperatorColor = lipgloss.Color(lightCyan)
- theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
-
- return theme
-}
-
-func init() {
- // Register the One Dark and One Light themes with the theme manager
- RegisterTheme("onedark", NewOneDarkTheme())
- RegisterTheme("onelight", NewOneLightTheme())
-}
diff --git a/internal/tui/theme/opencode.go b/internal/tui/theme/opencode.go
deleted file mode 100644
index 40bbaeca95066615e8abc3c9e8984e8f5f530a9d..0000000000000000000000000000000000000000
--- a/internal/tui/theme/opencode.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
-)
-
-// OpenCodeTheme implements the Theme interface with OpenCode brand colors.
-// It provides both dark and light variants.
-type OpenCodeTheme struct {
- BaseTheme
-}
-
-// NewOpenCodeDarkTheme creates a new instance of the OpenCode Dark theme.
-func NewOpenCodeDarkTheme() *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
-
- theme := &OpenCodeTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(darkPrimary)
- theme.SecondaryColor = lipgloss.Color(darkSecondary)
- theme.AccentColor = lipgloss.Color(darkAccent)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(darkRed)
- theme.WarningColor = lipgloss.Color(darkOrange)
- theme.SuccessColor = lipgloss.Color(darkGreen)
- theme.InfoColor = lipgloss.Color(darkCyan)
-
- // Text colors
- theme.TextColor = lipgloss.Color(darkForeground)
- theme.TextMutedColor = lipgloss.Color(darkComment)
- theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(darkBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#121212") // Slightly darker than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(darkBorder)
- theme.BorderFocusedColor = lipgloss.Color(darkPrimary)
- theme.BorderDimColor = lipgloss.Color(darkSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#478247")
- theme.DiffRemovedColor = lipgloss.Color("#7C4444")
- theme.DiffContextColor = lipgloss.Color("#a0a0a0")
- theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0")
- // TODO: change these colors to be what we want
- theme.DiffHighlightAddedColor = lipgloss.Color("#256125")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#612726")
- theme.DiffAddedBgColor = lipgloss.Color("#303A30")
- theme.DiffRemovedBgColor = lipgloss.Color("#3A3030")
- theme.DiffContextBgColor = lipgloss.Color(darkBackground)
- theme.DiffLineNumberColor = lipgloss.Color("#888888")
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#293229")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#332929")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(darkForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(darkSecondary)
- theme.MarkdownLinkColor = lipgloss.Color(darkPrimary)
- theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
- theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
- theme.MarkdownStrongColor = lipgloss.Color(darkAccent)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
- theme.MarkdownListItemColor = lipgloss.Color(darkPrimary)
- theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
- theme.MarkdownImageColor = lipgloss.Color(darkPrimary)
- theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(darkComment)
- theme.SyntaxKeywordColor = lipgloss.Color(darkSecondary)
- theme.SyntaxFunctionColor = lipgloss.Color(darkPrimary)
- theme.SyntaxVariableColor = lipgloss.Color(darkRed)
- theme.SyntaxStringColor = lipgloss.Color(darkGreen)
- theme.SyntaxNumberColor = lipgloss.Color(darkAccent)
- theme.SyntaxTypeColor = lipgloss.Color(darkYellow)
- theme.SyntaxOperatorColor = lipgloss.Color(darkCyan)
- theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
-
- return theme
-}
-
-// NewOpenCodeLightTheme creates a new instance of the OpenCode Light theme.
-func NewOpenCodeLightTheme() *OpenCodeTheme {
- // 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.Color(lightPrimary)
- theme.SecondaryColor = lipgloss.Color(lightSecondary)
- theme.AccentColor = lipgloss.Color(lightAccent)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(lightRed)
- theme.WarningColor = lipgloss.Color(lightOrange)
- theme.SuccessColor = lipgloss.Color(lightGreen)
- theme.InfoColor = lipgloss.Color(lightCyan)
-
- // Text colors
- theme.TextColor = lipgloss.Color(lightForeground)
- theme.TextMutedColor = lipgloss.Color(lightComment)
- theme.TextEmphasizedColor = lipgloss.Color(lightYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(lightBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#ffffff") // Slightly lighter than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(lightBorder)
- theme.BorderFocusedColor = lipgloss.Color(lightPrimary)
- theme.BorderDimColor = lipgloss.Color(lightSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#2E7D32")
- theme.DiffRemovedColor = lipgloss.Color("#C62828")
- theme.DiffContextColor = lipgloss.Color("#757575")
- theme.DiffHunkHeaderColor = lipgloss.Color("#757575")
- theme.DiffHighlightAddedColor = lipgloss.Color("#A5D6A7")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#EF9A9A")
- theme.DiffAddedBgColor = lipgloss.Color("#E8F5E9")
- theme.DiffRemovedBgColor = lipgloss.Color("#FFEBEE")
- theme.DiffContextBgColor = lipgloss.Color(lightBackground)
- theme.DiffLineNumberColor = lipgloss.Color("#9E9E9E")
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#C8E6C9")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#FFCDD2")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(lightForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(lightSecondary)
- theme.MarkdownLinkColor = lipgloss.Color(lightPrimary)
- theme.MarkdownLinkTextColor = lipgloss.Color(lightCyan)
- theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
- theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
- theme.MarkdownStrongColor = lipgloss.Color(lightAccent)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
- theme.MarkdownListItemColor = lipgloss.Color(lightPrimary)
- theme.MarkdownListEnumerationColor = lipgloss.Color(lightCyan)
- theme.MarkdownImageColor = lipgloss.Color(lightPrimary)
- theme.MarkdownImageTextColor = lipgloss.Color(lightCyan)
- theme.MarkdownCodeBlockColor = lipgloss.Color(lightForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(lightComment)
- theme.SyntaxKeywordColor = lipgloss.Color(lightSecondary)
- theme.SyntaxFunctionColor = lipgloss.Color(lightPrimary)
- theme.SyntaxVariableColor = lipgloss.Color(lightRed)
- theme.SyntaxStringColor = lipgloss.Color(lightGreen)
- theme.SyntaxNumberColor = lipgloss.Color(lightAccent)
- theme.SyntaxTypeColor = lipgloss.Color(lightYellow)
- theme.SyntaxOperatorColor = lipgloss.Color(lightCyan)
- theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
-
- return theme
-}
-
-func init() {
- // Register the OpenCode themes with the theme manager
- RegisterTheme("opencode-dark", NewOpenCodeDarkTheme())
- RegisterTheme("opencode-light", NewOpenCodeLightTheme())
-}
diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go
deleted file mode 100644
index c2221b5483f3de37c02df01ee71ed8b0e4ac01f8..0000000000000000000000000000000000000000
--- a/internal/tui/theme/theme.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package theme
-
-import (
- "image/color"
-)
-
-type Theme interface {
- // Base colors
- Primary() color.Color
- Secondary() color.Color
- Accent() color.Color
-
- // Status colors
- Error() color.Color
- Warning() color.Color
- Success() color.Color
- Info() color.Color
-
- // Text colors
- Text() color.Color
- TextMuted() color.Color
- TextEmphasized() color.Color
-
- // Background colors
- Background() color.Color
- BackgroundSecondary() color.Color
- BackgroundDarker() color.Color
-
- // Border colors
- BorderNormal() color.Color
- BorderFocused() color.Color
- BorderDim() color.Color
-
- // Diff view colors
- DiffAdded() color.Color
- DiffRemoved() color.Color
- DiffContext() color.Color
- DiffHunkHeader() color.Color
- DiffHighlightAdded() color.Color
- DiffHighlightRemoved() color.Color
- DiffAddedBg() color.Color
- DiffRemovedBg() color.Color
- DiffContextBg() color.Color
- DiffLineNumber() color.Color
- DiffAddedLineNumberBg() color.Color
- DiffRemovedLineNumberBg() color.Color
-
- // Markdown colors
- MarkdownText() color.Color
- MarkdownHeading() color.Color
- MarkdownLink() color.Color
- MarkdownLinkText() color.Color
- MarkdownCode() color.Color
- MarkdownBlockQuote() color.Color
- MarkdownEmph() color.Color
- MarkdownStrong() color.Color
- MarkdownHorizontalRule() color.Color
- MarkdownListItem() color.Color
- MarkdownListEnumeration() color.Color
- MarkdownImage() color.Color
- MarkdownImageText() color.Color
- MarkdownCodeBlock() color.Color
-
- // Syntax highlighting colors
- SyntaxComment() color.Color
- SyntaxKeyword() color.Color
- SyntaxFunction() color.Color
- SyntaxVariable() color.Color
- SyntaxString() color.Color
- SyntaxNumber() color.Color
- SyntaxType() color.Color
- SyntaxOperator() color.Color
- SyntaxPunctuation() color.Color
-}
-
-// BaseTheme provides a default implementation of the Theme interface
-// that can be embedded in concrete theme implementations.
-type BaseTheme struct {
- // Base colors
- PrimaryColor color.Color
- SecondaryColor color.Color
- AccentColor color.Color
-
- // Status colors
- ErrorColor color.Color
- WarningColor color.Color
- SuccessColor color.Color
- InfoColor color.Color
-
- // Text colors
- TextColor color.Color
- TextMutedColor color.Color
- TextEmphasizedColor color.Color
-
- // Background colors
- BackgroundColor color.Color
- BackgroundSecondaryColor color.Color
- BackgroundDarkerColor color.Color
-
- // Border colors
- BorderNormalColor color.Color
- BorderFocusedColor color.Color
- BorderDimColor color.Color
-
- // Diff view colors
- DiffAddedColor color.Color
- DiffRemovedColor color.Color
- DiffContextColor color.Color
- DiffHunkHeaderColor color.Color
- DiffHighlightAddedColor color.Color
- DiffHighlightRemovedColor color.Color
- DiffAddedBgColor color.Color
- DiffRemovedBgColor color.Color
- DiffContextBgColor color.Color
- DiffLineNumberColor color.Color
- DiffAddedLineNumberBgColor color.Color
- DiffRemovedLineNumberBgColor color.Color
-
- // Markdown colors
- MarkdownTextColor color.Color
- MarkdownHeadingColor color.Color
- MarkdownLinkColor color.Color
- MarkdownLinkTextColor color.Color
- MarkdownCodeColor color.Color
- MarkdownBlockQuoteColor color.Color
- MarkdownEmphColor color.Color
- MarkdownStrongColor color.Color
- MarkdownHorizontalRuleColor color.Color
- MarkdownListItemColor color.Color
- MarkdownListEnumerationColor color.Color
- MarkdownImageColor color.Color
- MarkdownImageTextColor color.Color
- MarkdownCodeBlockColor color.Color
-
- // Syntax highlighting colors
- SyntaxCommentColor color.Color
- SyntaxKeywordColor color.Color
- SyntaxFunctionColor color.Color
- SyntaxVariableColor color.Color
- SyntaxStringColor color.Color
- SyntaxNumberColor color.Color
- SyntaxTypeColor color.Color
- SyntaxOperatorColor color.Color
- SyntaxPunctuationColor color.Color
-}
-
-// Implement the Theme interface for BaseTheme
-func (t *BaseTheme) Primary() color.Color { return t.PrimaryColor }
-func (t *BaseTheme) Secondary() color.Color { return t.SecondaryColor }
-func (t *BaseTheme) Accent() color.Color { return t.AccentColor }
-
-func (t *BaseTheme) Error() color.Color { return t.ErrorColor }
-func (t *BaseTheme) Warning() color.Color { return t.WarningColor }
-func (t *BaseTheme) Success() color.Color { return t.SuccessColor }
-func (t *BaseTheme) Info() color.Color { return t.InfoColor }
-
-func (t *BaseTheme) Text() color.Color { return t.TextColor }
-func (t *BaseTheme) TextMuted() color.Color { return t.TextMutedColor }
-func (t *BaseTheme) TextEmphasized() color.Color { return t.TextEmphasizedColor }
-
-func (t *BaseTheme) Background() color.Color { return t.BackgroundColor }
-func (t *BaseTheme) BackgroundSecondary() color.Color { return t.BackgroundSecondaryColor }
-func (t *BaseTheme) BackgroundDarker() color.Color { return t.BackgroundDarkerColor }
-
-func (t *BaseTheme) BorderNormal() color.Color { return t.BorderNormalColor }
-func (t *BaseTheme) BorderFocused() color.Color { return t.BorderFocusedColor }
-func (t *BaseTheme) BorderDim() color.Color { return t.BorderDimColor }
-
-func (t *BaseTheme) DiffAdded() color.Color { return t.DiffAddedColor }
-func (t *BaseTheme) DiffRemoved() color.Color { return t.DiffRemovedColor }
-func (t *BaseTheme) DiffContext() color.Color { return t.DiffContextColor }
-func (t *BaseTheme) DiffHunkHeader() color.Color { return t.DiffHunkHeaderColor }
-func (t *BaseTheme) DiffHighlightAdded() color.Color { return t.DiffHighlightAddedColor }
-func (t *BaseTheme) DiffHighlightRemoved() color.Color { return t.DiffHighlightRemovedColor }
-func (t *BaseTheme) DiffAddedBg() color.Color { return t.DiffAddedBgColor }
-func (t *BaseTheme) DiffRemovedBg() color.Color { return t.DiffRemovedBgColor }
-func (t *BaseTheme) DiffContextBg() color.Color { return t.DiffContextBgColor }
-func (t *BaseTheme) DiffLineNumber() color.Color { return t.DiffLineNumberColor }
-func (t *BaseTheme) DiffAddedLineNumberBg() color.Color { return t.DiffAddedLineNumberBgColor }
-func (t *BaseTheme) DiffRemovedLineNumberBg() color.Color { return t.DiffRemovedLineNumberBgColor }
-
-func (t *BaseTheme) MarkdownText() color.Color { return t.MarkdownTextColor }
-func (t *BaseTheme) MarkdownHeading() color.Color { return t.MarkdownHeadingColor }
-func (t *BaseTheme) MarkdownLink() color.Color { return t.MarkdownLinkColor }
-func (t *BaseTheme) MarkdownLinkText() color.Color { return t.MarkdownLinkTextColor }
-func (t *BaseTheme) MarkdownCode() color.Color { return t.MarkdownCodeColor }
-func (t *BaseTheme) MarkdownBlockQuote() color.Color { return t.MarkdownBlockQuoteColor }
-func (t *BaseTheme) MarkdownEmph() color.Color { return t.MarkdownEmphColor }
-func (t *BaseTheme) MarkdownStrong() color.Color { return t.MarkdownStrongColor }
-func (t *BaseTheme) MarkdownHorizontalRule() color.Color { return t.MarkdownHorizontalRuleColor }
-func (t *BaseTheme) MarkdownListItem() color.Color { return t.MarkdownListItemColor }
-func (t *BaseTheme) MarkdownListEnumeration() color.Color { return t.MarkdownListEnumerationColor }
-func (t *BaseTheme) MarkdownImage() color.Color { return t.MarkdownImageColor }
-func (t *BaseTheme) MarkdownImageText() color.Color { return t.MarkdownImageTextColor }
-func (t *BaseTheme) MarkdownCodeBlock() color.Color { return t.MarkdownCodeBlockColor }
-
-func (t *BaseTheme) SyntaxComment() color.Color { return t.SyntaxCommentColor }
-func (t *BaseTheme) SyntaxKeyword() color.Color { return t.SyntaxKeywordColor }
-func (t *BaseTheme) SyntaxFunction() color.Color { return t.SyntaxFunctionColor }
-func (t *BaseTheme) SyntaxVariable() color.Color { return t.SyntaxVariableColor }
-func (t *BaseTheme) SyntaxString() color.Color { return t.SyntaxStringColor }
-func (t *BaseTheme) SyntaxNumber() color.Color { return t.SyntaxNumberColor }
-func (t *BaseTheme) SyntaxType() color.Color { return t.SyntaxTypeColor }
-func (t *BaseTheme) SyntaxOperator() color.Color { return t.SyntaxOperatorColor }
-func (t *BaseTheme) SyntaxPunctuation() color.Color { return t.SyntaxPunctuationColor }
diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go
deleted file mode 100644
index 790ee3aa8a37a3561da92ab56431f12646d050ec..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)
-}
diff --git a/internal/tui/theme/tokyonight.go b/internal/tui/theme/tokyonight.go
deleted file mode 100644
index 36fd976c3d9e69ffaa7d5b5203e7f7ad4d8a15dc..0000000000000000000000000000000000000000
--- a/internal/tui/theme/tokyonight.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
-)
-
-// 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"
-
- theme := &TokyoNightTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(darkBlue)
- theme.SecondaryColor = lipgloss.Color(darkPurple)
- theme.AccentColor = lipgloss.Color(darkOrange)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(darkRed)
- theme.WarningColor = lipgloss.Color(darkOrange)
- theme.SuccessColor = lipgloss.Color(darkGreen)
- theme.InfoColor = lipgloss.Color(darkBlue)
-
- // Text colors
- theme.TextColor = lipgloss.Color(darkForeground)
- theme.TextMutedColor = lipgloss.Color(darkComment)
- theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(darkBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#191B29") // Darker background from palette
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(darkBorder)
- theme.BorderFocusedColor = lipgloss.Color(darkBlue)
- theme.BorderDimColor = lipgloss.Color(darkSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#4fd6be") // teal from palette
- theme.DiffRemovedColor = lipgloss.Color("#c53b53") // red1 from palette
- theme.DiffContextColor = lipgloss.Color("#828bb8") // fg_dark from palette
- theme.DiffHunkHeaderColor = lipgloss.Color("#828bb8") // fg_dark from palette
- theme.DiffHighlightAddedColor = lipgloss.Color("#b8db87") // git.add from palette
- theme.DiffHighlightRemovedColor = lipgloss.Color("#e26a75") // git.delete from palette
- theme.DiffAddedBgColor = lipgloss.Color("#20303b")
- theme.DiffRemovedBgColor = lipgloss.Color("#37222c")
- theme.DiffContextBgColor = lipgloss.Color(darkBackground)
- theme.DiffLineNumberColor = lipgloss.Color("#545c7e") // dark3 from palette
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#1b2b34")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#2d1f26")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(darkForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(darkPurple)
- theme.MarkdownLinkColor = lipgloss.Color(darkBlue)
- theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
- theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
- theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
- theme.MarkdownListItemColor = lipgloss.Color(darkBlue)
- theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
- theme.MarkdownImageColor = lipgloss.Color(darkBlue)
- theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(darkComment)
- theme.SyntaxKeywordColor = lipgloss.Color(darkPurple)
- theme.SyntaxFunctionColor = lipgloss.Color(darkBlue)
- theme.SyntaxVariableColor = lipgloss.Color(darkRed)
- theme.SyntaxStringColor = lipgloss.Color(darkGreen)
- theme.SyntaxNumberColor = lipgloss.Color(darkOrange)
- theme.SyntaxTypeColor = lipgloss.Color(darkYellow)
- theme.SyntaxOperatorColor = lipgloss.Color(darkCyan)
- theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
-
- return theme
-}
-
-// NewTokyoNightDayTheme creates a new instance of the Tokyo Night Day theme.
-func NewTokyoNightDayTheme() *TokyoNightTheme {
- // 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.Color(lightBlue)
- theme.SecondaryColor = lipgloss.Color(lightPurple)
- theme.AccentColor = lipgloss.Color(lightOrange)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(lightRed)
- theme.WarningColor = lipgloss.Color(lightOrange)
- theme.SuccessColor = lipgloss.Color(lightGreen)
- theme.InfoColor = lipgloss.Color(lightBlue)
-
- // Text colors
- theme.TextColor = lipgloss.Color(lightForeground)
- theme.TextMutedColor = lipgloss.Color(lightComment)
- theme.TextEmphasizedColor = lipgloss.Color(lightYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(lightBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#f0f0f5") // Slightly lighter than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(lightBorder)
- theme.BorderFocusedColor = lipgloss.Color(lightBlue)
- theme.BorderDimColor = lipgloss.Color(lightSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color("#1e725c")
- theme.DiffRemovedColor = lipgloss.Color("#c53b53")
- theme.DiffContextColor = lipgloss.Color("#7086b5")
- theme.DiffHunkHeaderColor = lipgloss.Color("#7086b5")
- theme.DiffHighlightAddedColor = lipgloss.Color("#4db380")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#f52a65")
- theme.DiffAddedBgColor = lipgloss.Color("#d5e5d5")
- theme.DiffRemovedBgColor = lipgloss.Color("#f7d8db")
- theme.DiffContextBgColor = lipgloss.Color(lightBackground)
- theme.DiffLineNumberColor = lipgloss.Color("#848cb5")
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#c5d5c5")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#e7c8cb")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(lightForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(lightPurple)
- theme.MarkdownLinkColor = lipgloss.Color(lightBlue)
- theme.MarkdownLinkTextColor = lipgloss.Color(lightCyan)
- theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
- theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
- theme.MarkdownStrongColor = lipgloss.Color(lightOrange)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
- theme.MarkdownListItemColor = lipgloss.Color(lightBlue)
- theme.MarkdownListEnumerationColor = lipgloss.Color(lightCyan)
- theme.MarkdownImageColor = lipgloss.Color(lightBlue)
- theme.MarkdownImageTextColor = lipgloss.Color(lightCyan)
- theme.MarkdownCodeBlockColor = lipgloss.Color(lightForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(lightComment)
- theme.SyntaxKeywordColor = lipgloss.Color(lightPurple)
- theme.SyntaxFunctionColor = lipgloss.Color(lightBlue)
- theme.SyntaxVariableColor = lipgloss.Color(lightRed)
- theme.SyntaxStringColor = lipgloss.Color(lightGreen)
- theme.SyntaxNumberColor = lipgloss.Color(lightOrange)
- theme.SyntaxTypeColor = lipgloss.Color(lightYellow)
- theme.SyntaxOperatorColor = lipgloss.Color(lightCyan)
- theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
-
- return theme
-}
-
-func init() {
- // Register the Tokyo Night themes with the theme manager
- RegisterTheme("tokyonight", NewTokyoNightTheme())
- RegisterTheme("tokyonight-day", NewTokyoNightDayTheme())
-}
diff --git a/internal/tui/theme/tron.go b/internal/tui/theme/tron.go
deleted file mode 100644
index 9e08f88c9a04e7e617f12434d8a233e2791a4b1e..0000000000000000000000000000000000000000
--- a/internal/tui/theme/tron.go
+++ /dev/null
@@ -1,198 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
-)
-
-// 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"
-
- theme := &TronTheme{}
-
- // Base colors
- theme.PrimaryColor = lipgloss.Color(darkCyan)
- theme.SecondaryColor = lipgloss.Color(darkBlue)
- theme.AccentColor = lipgloss.Color(darkOrange)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(darkRed)
- theme.WarningColor = lipgloss.Color(darkOrange)
- theme.SuccessColor = lipgloss.Color(darkGreen)
- theme.InfoColor = lipgloss.Color(darkCyan)
-
- // Text colors
- theme.TextColor = lipgloss.Color(darkForeground)
- theme.TextMutedColor = lipgloss.Color(darkComment)
- theme.TextEmphasizedColor = lipgloss.Color(darkYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(darkBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(darkCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#070d14") // Slightly darker than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(darkBorder)
- theme.BorderFocusedColor = lipgloss.Color(darkCyan)
- theme.BorderDimColor = lipgloss.Color(darkSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color(darkGreen)
- theme.DiffRemovedColor = lipgloss.Color(darkRed)
- theme.DiffContextColor = lipgloss.Color(darkComment)
- theme.DiffHunkHeaderColor = lipgloss.Color(darkBlue)
- theme.DiffHighlightAddedColor = lipgloss.Color("#00ff8f")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#ff3333")
- theme.DiffAddedBgColor = lipgloss.Color("#0a2a1a")
- theme.DiffRemovedBgColor = lipgloss.Color("#2a0a0a")
- theme.DiffContextBgColor = lipgloss.Color(darkBackground)
- theme.DiffLineNumberColor = lipgloss.Color(darkComment)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#082015")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#200808")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(darkForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(darkCyan)
- theme.MarkdownLinkColor = lipgloss.Color(darkBlue)
- theme.MarkdownLinkTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeColor = lipgloss.Color(darkGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(darkYellow)
- theme.MarkdownEmphColor = lipgloss.Color(darkYellow)
- theme.MarkdownStrongColor = lipgloss.Color(darkOrange)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(darkComment)
- theme.MarkdownListItemColor = lipgloss.Color(darkBlue)
- theme.MarkdownListEnumerationColor = lipgloss.Color(darkCyan)
- theme.MarkdownImageColor = lipgloss.Color(darkBlue)
- theme.MarkdownImageTextColor = lipgloss.Color(darkCyan)
- theme.MarkdownCodeBlockColor = lipgloss.Color(darkForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(darkComment)
- theme.SyntaxKeywordColor = lipgloss.Color(darkCyan)
- theme.SyntaxFunctionColor = lipgloss.Color(darkGreen)
- theme.SyntaxVariableColor = lipgloss.Color(darkOrange)
- theme.SyntaxStringColor = lipgloss.Color(darkYellow)
- theme.SyntaxNumberColor = lipgloss.Color(darkBlue)
- theme.SyntaxTypeColor = lipgloss.Color(darkPurple)
- theme.SyntaxOperatorColor = lipgloss.Color(darkPink)
- theme.SyntaxPunctuationColor = lipgloss.Color(darkForeground)
-
- return theme
-}
-
-// NewTronLightTheme creates a new instance of the Tron Light theme.
-func NewTronLightTheme() *TronTheme {
- // 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.Color(lightCyan)
- theme.SecondaryColor = lipgloss.Color(lightBlue)
- theme.AccentColor = lipgloss.Color(lightOrange)
-
- // Status colors
- theme.ErrorColor = lipgloss.Color(lightRed)
- theme.WarningColor = lipgloss.Color(lightOrange)
- theme.SuccessColor = lipgloss.Color(lightGreen)
- theme.InfoColor = lipgloss.Color(lightCyan)
-
- // Text colors
- theme.TextColor = lipgloss.Color(lightForeground)
- theme.TextMutedColor = lipgloss.Color(lightComment)
- theme.TextEmphasizedColor = lipgloss.Color(lightYellow)
-
- // Background colors
- theme.BackgroundColor = lipgloss.Color(lightBackground)
- theme.BackgroundSecondaryColor = lipgloss.Color(lightCurrentLine)
- theme.BackgroundDarkerColor = lipgloss.Color("#ffffff") // Slightly lighter than background
-
- // Border colors
- theme.BorderNormalColor = lipgloss.Color(lightBorder)
- theme.BorderFocusedColor = lipgloss.Color(lightCyan)
- theme.BorderDimColor = lipgloss.Color(lightSelection)
-
- // Diff view colors
- theme.DiffAddedColor = lipgloss.Color(lightGreen)
- theme.DiffRemovedColor = lipgloss.Color(lightRed)
- theme.DiffContextColor = lipgloss.Color(lightComment)
- theme.DiffHunkHeaderColor = lipgloss.Color(lightBlue)
- theme.DiffHighlightAddedColor = lipgloss.Color("#a5d6a7")
- theme.DiffHighlightRemovedColor = lipgloss.Color("#ef9a9a")
- theme.DiffAddedBgColor = lipgloss.Color("#e8f5e9")
- theme.DiffRemovedBgColor = lipgloss.Color("#ffebee")
- theme.DiffContextBgColor = lipgloss.Color(lightBackground)
- theme.DiffLineNumberColor = lipgloss.Color(lightComment)
- theme.DiffAddedLineNumberBgColor = lipgloss.Color("#c8e6c9")
- theme.DiffRemovedLineNumberBgColor = lipgloss.Color("#ffcdd2")
-
- // Markdown colors
- theme.MarkdownTextColor = lipgloss.Color(lightForeground)
- theme.MarkdownHeadingColor = lipgloss.Color(lightCyan)
- theme.MarkdownLinkColor = lipgloss.Color(lightBlue)
- theme.MarkdownLinkTextColor = lipgloss.Color(lightCyan)
- theme.MarkdownCodeColor = lipgloss.Color(lightGreen)
- theme.MarkdownBlockQuoteColor = lipgloss.Color(lightYellow)
- theme.MarkdownEmphColor = lipgloss.Color(lightYellow)
- theme.MarkdownStrongColor = lipgloss.Color(lightOrange)
- theme.MarkdownHorizontalRuleColor = lipgloss.Color(lightComment)
- theme.MarkdownListItemColor = lipgloss.Color(lightBlue)
- theme.MarkdownListEnumerationColor = lipgloss.Color(lightCyan)
- theme.MarkdownImageColor = lipgloss.Color(lightBlue)
- theme.MarkdownImageTextColor = lipgloss.Color(lightCyan)
- theme.MarkdownCodeBlockColor = lipgloss.Color(lightForeground)
-
- // Syntax highlighting colors
- theme.SyntaxCommentColor = lipgloss.Color(lightComment)
- theme.SyntaxKeywordColor = lipgloss.Color(lightCyan)
- theme.SyntaxFunctionColor = lipgloss.Color(lightGreen)
- theme.SyntaxVariableColor = lipgloss.Color(lightOrange)
- theme.SyntaxStringColor = lipgloss.Color(lightYellow)
- theme.SyntaxNumberColor = lipgloss.Color(lightBlue)
- theme.SyntaxTypeColor = lipgloss.Color(lightPurple)
- theme.SyntaxOperatorColor = lipgloss.Color(lightPink)
- theme.SyntaxPunctuationColor = lipgloss.Color(lightForeground)
-
- return theme
-}
-
-func init() {
- // Register the Tron themes with the theme manager
- RegisterTheme("tron", NewTronTheme())
- RegisterTheme("tron-light", NewTronLightTheme())
-}
From 3311aed0bdb05ee9ebdd533a08791aa7b390231a Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Wed, 4 Jun 2025 21:43:44 +0200
Subject: [PATCH 48/73] add shortcuts
---
internal/tui/components/completions/item.go | 87 +++++++++++--------
internal/tui/components/core/status/keys.go | 6 ++
.../components/dialogs/commands/commands.go | 14 ++-
.../tui/components/dialogs/commands/keys.go | 18 ++--
internal/tui/components/dialogs/dialogs.go | 13 +--
.../tui/components/dialogs/models/keys.go | 16 ++--
.../tui/components/dialogs/models/models.go | 6 +-
internal/tui/components/dialogs/quit/keys.go | 15 ++--
internal/tui/components/dialogs/quit/quit.go | 10 +--
.../tui/components/dialogs/sessions/keys.go | 16 ++--
.../components/dialogs/sessions/sessions.go | 6 +-
internal/tui/keys.go | 5 ++
internal/tui/tui.go | 27 ++++++
13 files changed, 157 insertions(+), 82 deletions(-)
diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go
index f7a2f628115fb4dee6957cf6b6968b7375b40e7f..324c07249bc784366e33f717d5a59d20b2eff7bf 100644
--- a/internal/tui/components/completions/item.go
+++ b/internal/tui/components/completions/item.go
@@ -29,23 +29,30 @@ type completionItemCmp struct {
focus bool
matchIndexes []int
bgColor color.Color
+ shortcut string
}
-type completionOptions func(*completionItemCmp)
+type CompletionOption func(*completionItemCmp)
-func WithBackgroundColor(c color.Color) completionOptions {
+func WithBackgroundColor(c color.Color) CompletionOption {
return func(cmp *completionItemCmp) {
cmp.bgColor = c
}
}
-func WithMatchIndexes(indexes ...int) completionOptions {
+func WithMatchIndexes(indexes ...int) CompletionOption {
return func(cmp *completionItemCmp) {
cmp.matchIndexes = indexes
}
}
-func NewCompletionItem(text string, value any, opts ...completionOptions) CompletionItem {
+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,
@@ -71,7 +78,14 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
func (c *completionItemCmp) View() tea.View {
t := styles.CurrentTheme()
- titleStyle := t.S().Text.Padding(0, 1).Width(c.width)
+ 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)
@@ -79,36 +93,49 @@ func (c *completionItemCmp) View() tea.View {
}
if c.focus {
- titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width)
+ titleStyle = t.S().TextSelected.Width(innerWidth)
titleMatchStyle = t.S().TextSelected.Underline(true)
+ itemStyle = itemStyle.Background(t.Primary)
}
var truncatedTitle string
- var adjustedMatchIndexes []int
- availableWidth := c.width - 2 // Account for padding
- if len(c.matchIndexes) > 0 && len(c.text) > availableWidth {
+ if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
// Smart truncation: ensure the last matching part is visible
- truncatedTitle, adjustedMatchIndexes = c.smartTruncate(c.text, availableWidth, c.matchIndexes)
+ truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
} else {
// No matches, use regular truncation
- truncatedTitle = ansi.Truncate(c.text, availableWidth, "…")
- adjustedMatchIndexes = c.matchIndexes
+ truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
}
text := titleStyle.Render(truncatedTitle)
- if len(adjustedMatchIndexes) > 0 {
+ if len(c.matchIndexes) > 0 {
var ranges []lipgloss.Range
- for _, rng := range matchedRanges(adjustedMatchIndexes) {
+ 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(text, rng)
+ start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
}
text = lipgloss.StyleRanges(text, ranges...)
}
- return tea.NewView(text)
+ 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.
@@ -141,9 +168,6 @@ func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
func (c *completionItemCmp) MatchIndexes(indexes []int) {
c.matchIndexes = indexes
- for i := range c.matchIndexes {
- c.matchIndexes[i] += 1 // Adjust for the padding we add in View
- }
}
func (c *completionItemCmp) FilterValue() string {
@@ -155,18 +179,18 @@ func (c *completionItemCmp) Value() any {
}
// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
-func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) (string, []int) {
+func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
if width <= 0 {
- return "", []int{}
+ return ""
}
textLen := ansi.StringWidth(text)
if textLen <= width {
- return text, matchIndexes
+ return text
}
if len(matchIndexes) == 0 {
- return ansi.Truncate(text, width, "…"), []int{}
+ return ansi.Truncate(text, width, "…")
}
// Find the last match position
@@ -187,7 +211,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
// If the last match is within the available width, truncate from the end
if lastMatchVisualPos < availableWidth {
- return ansi.Truncate(text, width, "…"), matchIndexes
+ return ansi.Truncate(text, width, "…")
}
// Calculate the start position to ensure the last match is visible
@@ -209,20 +233,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
// Truncate to fit width with ellipsis
truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
truncatedText = "…" + truncatedText
-
- // Adjust match indexes for the new truncated string
- adjustedIndexes := []int{}
- for _, idx := range matchIndexes {
- if idx >= startBytePos {
- newIdx := idx - startBytePos + 1 //
- // Check if this match is still within the truncated string
- if newIdx < len(truncatedText) {
- adjustedIndexes = append(adjustedIndexes, newIdx)
- }
- }
- }
-
- return truncatedText, adjustedIndexes
+ return truncatedText
}
func matchedRanges(in []int) [][2]int {
diff --git a/internal/tui/components/core/status/keys.go b/internal/tui/components/core/status/keys.go
index 245f4328bb2ee82fdb29777f1e5b482e3277e198..1c7a794ba96c1618cdef986c48ff36c492d1bacf 100644
--- a/internal/tui/components/core/status/keys.go
+++ b/internal/tui/components/core/status/keys.go
@@ -8,6 +8,7 @@ import (
type KeyMap struct {
Tab,
Commands,
+ Sessions,
Help key.Binding
}
@@ -21,6 +22,10 @@ func DefaultKeyMap(tabHelp string) KeyMap {
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"),
@@ -44,6 +49,7 @@ func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{
k.Tab,
k.Commands,
+ k.Sessions,
k.Help,
}
}
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index 127b11dcfd8ea8666a59db30346537633a299e9c..90ca45fa8a801bd8122fb0ebee9e855e46c08092 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -16,7 +16,7 @@ import (
)
const (
- commandsDialogID dialogs.DialogID = "commands"
+ CommandsDialogID dialogs.DialogID = "commands"
defaultWidth int = 70
)
@@ -31,6 +31,7 @@ type Command struct {
ID string
Title string
Description string
+ Shortcut string // Optional shortcut for the command
Handler func(cmd Command) tea.Cmd
}
@@ -126,6 +127,8 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} 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)
@@ -181,7 +184,11 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
commandItems := []util.Model{}
for _, cmd := range commands {
- commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
+ 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)
}
@@ -250,6 +257,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
ID: "switch_session",
Title: "Switch Session",
Description: "Switch to a different session",
+ Shortcut: "ctrl+s",
Handler: func(cmd Command) tea.Cmd {
return func() tea.Msg {
return SwitchSessionsMsg{}
@@ -270,5 +278,5 @@ func (c *commandDialogCmp) defaultCommands() []Command {
}
func (c *commandDialogCmp) ID() dialogs.DialogID {
- return commandsDialogID
+ return CommandsDialogID
}
diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go
index 7bfe0fb69675c8e2c04edc78d59ac0dda05415cd..9b80591678b97af6c70aa2794e9e980d229fe441 100644
--- a/internal/tui/components/dialogs/commands/keys.go
+++ b/internal/tui/components/dialogs/commands/keys.go
@@ -6,10 +6,11 @@ import (
)
type CommandsDialogKeyMap struct {
- Select key.Binding
- Next key.Binding
- Previous key.Binding
- Tab key.Binding
+ Select,
+ Next,
+ Previous,
+ Tab,
+ Close key.Binding
}
func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
@@ -30,6 +31,10 @@ func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
key.WithKeys("tab"),
key.WithHelp("tab", "switch selection"),
),
+ Close: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
}
}
@@ -53,10 +58,7 @@ func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
key.WithHelp("↑↓", "choose"),
),
k.Select,
- key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
+ k.Close,
}
}
diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go
index f5e5e285de96ed7b59e0f6600ef9eb78548c22cd..58a25ae446309ca3f33bfb1aafc407453fff61f6 100644
--- a/internal/tui/components/dialogs/dialogs.go
+++ b/internal/tui/components/dialogs/dialogs.go
@@ -3,7 +3,6 @@ package dialogs
import (
"slices"
- "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/util"
@@ -39,6 +38,7 @@ type DialogCmp interface {
HasDialogs() bool
GetLayers() []*lipgloss.Layer
ActiveView() *tea.View
+ ActiveDialogId() DialogID
}
type dialogCmp struct {
@@ -88,10 +88,6 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return d, closeable.Close()
}
return d, nil
- case tea.KeyPressMsg:
- if key.Matches(msg, d.keyMap.Close) {
- return d, util.CmdHandler(CloseDialogMsg{})
- }
}
if d.HasDialogs() {
lastIndex := len(d.dialogs) - 1
@@ -144,6 +140,13 @@ func (d dialogCmp) ActiveView() *tea.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() {
diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go
index 50bec18f2f51fd695582d7cf5f799fffaee8d577..17d21193edaf6b6bfa1ec4f53a9e91b8fba28b80 100644
--- a/internal/tui/components/dialogs/models/keys.go
+++ b/internal/tui/components/dialogs/models/keys.go
@@ -6,9 +6,10 @@ import (
)
type KeyMap struct {
- Select key.Binding
- Next key.Binding
- Previous key.Binding
+ Select,
+ Next,
+ Previous,
+ Close key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("up", "ctrl+p"),
key.WithHelp("↑", "previous item"),
),
+ Close: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
}
}
@@ -48,9 +53,6 @@ func (k KeyMap) ShortHelp() []key.Binding {
key.WithHelp("↑↓", "choose"),
),
k.Select,
- key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
+ k.Close,
}
}
diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go
index b2ee4e8bb6fd7631a03c90c46a7bbb2cab8b274c..8cb19998b87891c560971ff37d734b7858a59ee6 100644
--- a/internal/tui/components/dialogs/models/models.go
+++ b/internal/tui/components/dialogs/models/models.go
@@ -19,7 +19,7 @@ import (
)
const (
- ID dialogs.DialogID = "models"
+ ModelsDialogID dialogs.DialogID = "models"
defaultWidth = 60
)
@@ -145,6 +145,8 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
@@ -257,5 +259,5 @@ func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
}
func (m *modelDialogCmp) ID() dialogs.DialogID {
- return ID
+ return ModelsDialogID
}
diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go
index a2459af696d16ed497565b71775887d7f75f317d..426bcc6c38b03257e81088fd7a2c6534e4facb6e 100644
--- a/internal/tui/components/dialogs/quit/keys.go
+++ b/internal/tui/components/dialogs/quit/keys.go
@@ -7,11 +7,12 @@ import (
// KeyMap defines the keyboard bindings for the quit dialog.
type KeyMap struct {
- LeftRight key.Binding
- EnterSpace key.Binding
- Yes key.Binding
- No key.Binding
- Tab key.Binding
+ LeftRight,
+ EnterSpace,
+ Yes,
+ No,
+ Tab,
+ Close key.Binding
}
func DefaultKeymap() KeyMap {
@@ -36,6 +37,10 @@ func DefaultKeymap() KeyMap {
key.WithKeys("tab"),
key.WithHelp("tab", "switch options"),
),
+ Close: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
}
}
diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go
index df0dbf8887bbbb8d512de4dc911448e695ff62d8..d370be34a2e5283deb37f7ea0a397d9817515671 100644
--- a/internal/tui/components/dialogs/quit/quit.go
+++ b/internal/tui/components/dialogs/quit/quit.go
@@ -11,8 +11,8 @@ import (
)
const (
- question = "Are you sure you want to quit?"
- id dialogs.DialogID = "quit"
+ question = "Are you sure you want to quit?"
+ QuitDialogID dialogs.DialogID = "quit"
)
// QuitDialog represents a confirmation dialog for quitting the application.
@@ -49,7 +49,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
q.wHeight = msg.Height
case tea.KeyPressMsg:
switch {
- case key.Matches(msg, q.keymap.LeftRight) || key.Matches(msg, q.keymap.Tab):
+ case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab):
q.selectedNo = !q.selectedNo
return q, nil
case key.Matches(msg, q.keymap.EnterSpace):
@@ -59,7 +59,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return q, util.CmdHandler(dialogs.CloseDialogMsg{})
case key.Matches(msg, q.keymap.Yes):
return q, tea.Quit
- case key.Matches(msg, q.keymap.No):
+ case key.Matches(msg, q.keymap.No, q.keymap.Close):
return q, util.CmdHandler(dialogs.CloseDialogMsg{})
}
}
@@ -121,5 +121,5 @@ func (q *quitDialogCmp) Position() (int, int) {
}
func (q *quitDialogCmp) ID() dialogs.DialogID {
- return id
+ return QuitDialogID
}
diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go
index 2ec423d865cbcaa330f87dc652b60556c4886f33..91cc069c18804e0bdde3557f9a24f54dceb9cdc8 100644
--- a/internal/tui/components/dialogs/sessions/keys.go
+++ b/internal/tui/components/dialogs/sessions/keys.go
@@ -6,9 +6,10 @@ import (
)
type KeyMap struct {
- Select key.Binding
- Next key.Binding
- Previous key.Binding
+ Select,
+ Next,
+ Previous,
+ Close key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("up", "ctrl+p"),
key.WithHelp("↑", "previous item"),
),
+ Close: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
}
}
@@ -48,9 +53,6 @@ func (k KeyMap) ShortHelp() []key.Binding {
key.WithHelp("↑↓", "choose"),
),
k.Select,
- key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
+ k.Close,
}
}
diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go
index e64de9b2ccdfd974724f9f12bf8745072df01333..31a8c8c2bf916db16333f5b152ac78a0e4b98d30 100644
--- a/internal/tui/components/dialogs/sessions/sessions.go
+++ b/internal/tui/components/dialogs/sessions/sessions.go
@@ -15,7 +15,7 @@ import (
"github.com/opencode-ai/opencode/internal/tui/util"
)
-const id dialogs.DialogID = "sessions"
+const SessionsDialogID dialogs.DialogID = "sessions"
// SessionDialog interface for the session switching dialog
type SessionDialog interface {
@@ -113,6 +113,8 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
),
)
}
+ 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)
@@ -174,5 +176,5 @@ func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
// ID implements SessionDialog.
func (s *sessionDialogCmp) ID() dialogs.DialogID {
- return id
+ return SessionsDialogID
}
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index 8fe13c3986f30ada4a8ac9a2661044e913eda6b3..96dbab01400f622e8d3e224e3f626f206d4ab68f 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -10,6 +10,7 @@ type KeyMap struct {
Quit key.Binding
Help key.Binding
Commands key.Binding
+ Sessions key.Binding
FilePicker key.Binding
}
@@ -32,6 +33,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("ctrl+p"),
key.WithHelp("ctrl+p", "commands"),
),
+ Sessions: key.NewBinding(
+ key.WithKeys("ctrl+s"),
+ key.WithHelp("ctrl+s", "sessions"),
+ ),
FilePicker: key.NewBinding(
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "select files to upload"),
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index d9d2dc5c728bf775b4c05f7440f32c894e6be0c9..71e0ea7dc64b0a1e2ffa094564f8287e440f6452 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -189,14 +189,41 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
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
+ }
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{})
+ }
return util.CmdHandler(dialogs.OpenDialogMsg{
Model: commands.NewCommandDialog(),
})
+ 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{})
+ }
+ 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{}))
+ }
+ 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(page.LogsPage)
From 3eb2a938d3a1acedcd256e2493fa47df5db12cd8 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Tue, 3 Jun 2025 15:31:16 -0400
Subject: [PATCH 49/73] fix(lint): remove extraneous newline
---
internal/tui/components/core/list/list.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 6c7d34777f7622d1f474b5dfe7e4fc3553a1420e..247672e53d76e931e04dd74c3f7d4690f7162cd9 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -345,7 +345,6 @@ func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
m.currentSearch = m.input.Value()
return m, tea.Batch(cmds...)
-
}
return m, nil
}
From db179e7f31eaad6124e7a80a212446cca65154ce Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Thu, 5 Jun 2025 23:08:50 +0200
Subject: [PATCH 50/73] initial message revamp
---
internal/tui/components/chat/chat.go | 51 ++++++-----
internal/tui/components/chat/editor/editor.go | 2 +-
.../tui/components/chat/messages/messages.go | 39 +++++----
.../tui/components/chat/messages/renderer.go | 84 +++++++++++++------
internal/tui/components/chat/messages/tool.go | 30 +++----
internal/tui/components/core/list/keys.go | 12 +--
internal/tui/components/core/list/list.go | 13 +++
internal/tui/styles/icons.go | 7 +-
internal/tui/styles/theme.go | 8 +-
9 files changed, 151 insertions(+), 95 deletions(-)
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index 4abff286babc4c3609371fc084567f7c96d91cd3..eaff4c7e3b697abd18aa9b2831ed2814e4c7c2cf 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -4,7 +4,6 @@ import (
"context"
"time"
- "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/app"
@@ -36,16 +35,19 @@ const (
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
+ app *app.App
+ width, height int
+ session session.Session
+ listCmp list.ListModel
+ focused bool // Focus state for styling
+ previousSelected int // Last selected item index for restoring focus
lastUserMessageTime int64
}
@@ -54,20 +56,6 @@ type messageListCmp struct {
// and reverse ordering (newest messages at bottom).
func NewMessagesListCmp(app *app.App) MessageListCmp {
defaultKeymaps := list.DefaultKeyMap()
- defaultKeymaps.Up.SetEnabled(false)
- defaultKeymaps.Down.SetEnabled(false)
- defaultKeymaps.NDown = key.NewBinding(
- key.WithKeys("ctrl+j"),
- )
- defaultKeymaps.NUp = key.NewBinding(
- key.WithKeys("ctrl+k"),
- )
- defaultKeymaps.Home = key.NewBinding(
- key.WithKeys("ctrl+shift+up"),
- )
- defaultKeymaps.End = key.NewBinding(
- key.WithKeys("ctrl+shift+down"),
- )
return &messageListCmp{
app: app,
listCmp: list.New(
@@ -75,6 +63,7 @@ func NewMessagesListCmp(app *app.App) MessageListCmp {
list.WithReverse(true),
list.WithKeyMap(defaultKeymaps),
),
+ previousSelected: list.NoSelection,
}
}
@@ -491,3 +480,27 @@ func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
m.height = height - 1
return m.listCmp.SetSize(width, height-1)
}
+
+// Blur implements MessageListCmp.
+func (m *messageListCmp) Blur() tea.Cmd {
+ m.focused = false
+ m.previousSelected = m.listCmp.SelectedIndex()
+ m.listCmp.ClearSelection()
+ return nil
+}
+
+// Focus implements MessageListCmp.
+func (m *messageListCmp) Focus() tea.Cmd {
+ m.focused = true
+ if m.previousSelected != list.NoSelection {
+ m.listCmp.SetSelected(m.previousSelected)
+ } else {
+ m.listCmp.SetSelected(len(m.listCmp.Items()) - 1)
+ }
+ return nil
+}
+
+// IsFocused implements MessageListCmp.
+func (m *messageListCmp) IsFocused() bool {
+ return m.focused
+}
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index e9e565daade4c855c9a50fa7f84cfd0a07d2b016..096e6c5e97ce8d6f2a3fc243fe44fa095a858f11 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -361,7 +361,7 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
return " > "
}
if focused {
- return t.S().Base.Foreground(t.Blue).Render("::: ")
+ return t.S().Base.Foreground(t.GreenDark).Render("::: ")
} else {
return t.S().Muted.Render("::: ")
}
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index 647db5595978fc44b44d82e4bcf54fddaaebe3f6..f04fe6a08a97175d2c69a01f75096394a2d3aef5 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -2,7 +2,6 @@ package messages
import (
"fmt"
- "image/color"
"path/filepath"
"strings"
"time"
@@ -14,6 +13,7 @@ import (
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/tui/components/anim"
+ "github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/util"
@@ -117,38 +117,35 @@ func (m *messageCmp) GetMessage() message.Message {
// textWidth calculates the available width for text content,
// accounting for borders and padding
func (m *messageCmp) textWidth() int {
- return m.width - 1 // take into account the border
+ 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()
- var borderColor color.Color
borderStyle := lipgloss.NormalBorder()
if msg.focused {
- borderStyle = lipgloss.DoubleBorder()
+ borderStyle = lipgloss.ThickBorder()
}
- switch msg.message.Role {
- case message.User:
- borderColor = t.Secondary
- case message.Assistant:
- borderColor = t.Primary
- default:
- // Tool call
- borderColor = t.BgSubtle
+ 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 t.S().Muted.
- BorderLeft(true).
- BorderForeground(borderColor).
- BorderStyle(borderStyle)
+ 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(),
}
@@ -170,7 +167,8 @@ func (m *messageCmp) renderAssistantMessage() string {
case message.FinishReasonPermissionDenied:
infoMsg = "permission denied"
}
- parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
+ 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...)
@@ -202,7 +200,7 @@ func (m *messageCmp) renderUserMessage() string {
parts = append(parts, "", strings.Join(attachments, ""))
}
joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
- return m.style().Render(joined)
+ return m.style().MarginBottom(1).Render(joined)
}
// toMarkdown converts text content to rendered markdown using the configured renderer
@@ -280,7 +278,8 @@ func (m *messageCmp) GetSize() (int, int) {
// SetSize updates the width of the message component for text wrapping
func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
- m.width = width
+ // For better readability, we limit the width to a maximum of 120 characters
+ m.width = min(width, 120)
return nil
}
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index ee8d679529505e8e984a0b5fa5c57ccb9d266b57..c67d2bbed7c8793969400459e81325b5c92cdf56 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -3,6 +3,7 @@ package messages
import (
"encoding/json"
"fmt"
+ "os"
"strings"
"time"
@@ -95,7 +96,7 @@ func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []
if v.isNested {
width -= 4 // Adjust for nested tool call indentation
}
- header := makeHeader(toolName, width, args...)
+ header := br.makeHeader(v, toolName, width, args...)
if v.isNested {
return v.style().Render(header)
}
@@ -111,6 +112,32 @@ 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{} })
@@ -167,12 +194,6 @@ func (br bashRenderer) Render(v *toolCallCmp) string {
})
}
-// renderError provides consistent error rendering
-func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
- header := makeHeader("Error", v.textWidth(), message)
- return joinHeaderBody(header, "")
-}
-
// -----------------------------------------------------------------------------
// View renderer
// -----------------------------------------------------------------------------
@@ -189,7 +210,7 @@ func (vr viewRenderer) Render(v *toolCallCmp) string {
return vr.renderError(v, "Invalid view parameters")
}
- file := removeWorkingDirPrefix(params.FilePath)
+ file := prettyPath(params.FilePath)
args := newParamBuilder().
addMain(file).
addKeyValue("limit", formatNonZero(params.Limit)).
@@ -229,7 +250,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
return er.renderError(v, "Invalid edit parameters")
}
- file := removeWorkingDirPrefix(params.FilePath)
+ file := prettyPath(params.FilePath)
args := newParamBuilder().addMain(file).build()
return er.renderWithParams(v, "Edit", args, func() string {
@@ -239,7 +260,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
}
trunc := truncateHeight(meta.Diff, responseContextHeight)
- diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
+ diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()-2))
return diffView
})
}
@@ -260,7 +281,7 @@ func (wr writeRenderer) Render(v *toolCallCmp) string {
return wr.renderError(v, "Invalid write parameters")
}
- file := removeWorkingDirPrefix(params.FilePath)
+ file := prettyPath(params.FilePath)
args := newParamBuilder().addMain(file).build()
return wr.renderWithParams(v, "Write", args, func() string {
@@ -494,7 +515,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
prompt = strings.ReplaceAll(prompt, "\n", " ")
args := newParamBuilder().addMain(prompt).build()
- header := makeHeader("Task", v.textWidth(), args...)
+ header := tr.makeHeader(v, "Task", v.textWidth(), args...)
t := tree.Root(header)
for _, call := range v.nestedToolCalls {
@@ -524,12 +545,6 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
return joinHeaderBody(header, body)
}
-// makeHeader builds ": param (key=value)" and truncates as needed.
-func makeHeader(tool string, width int, params ...string) string {
- prefix := tool + ": "
- return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
-}
-
// renderParamList renders params, params[0] (params[1]=params[2] ....)
func renderParamList(paramsWidth int, params ...string) string {
if len(params) == 0 {
@@ -575,20 +590,27 @@ func renderParamList(paramsWidth int, params ...string) string {
// 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:
- return lipgloss.JoinVertical(lipgloss.Left, header, v.renderToolError()), true
+ message = v.renderToolError()
case v.cancelled:
- return lipgloss.JoinVertical(lipgloss.Left, header, "Cancelled"), true
+ message = "Cancelled"
case v.result.ToolCallID == "":
- return lipgloss.JoinVertical(lipgloss.Left, header, "Waiting for tool to finish..."), true
+ 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 {
- return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
+ 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 {
@@ -596,17 +618,18 @@ func renderPlainContent(v *toolCallCmp, content string) string {
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) > v.textWidth() {
- ln = v.fit(ln, v.textWidth())
+ if len(ln) > width {
+ ln = v.fit(ln, width)
}
out = append(out, t.S().Muted.
- Width(v.textWidth()).
+ Width(width).
Background(t.BgSubtle).
Render(ln))
}
@@ -638,7 +661,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
PaddingLeft(4).
PaddingRight(2).
Render(fmt.Sprintf("%d", i+1+offset))
- w := v.textWidth() - lipgloss.Width(num)
+ w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
num,
t.S().Base.
@@ -669,6 +692,15 @@ func truncateHeight(s string, h int) string {
return s
}
+func prettyPath(path string) string {
+ // replace home directory with ~
+ homeDir, err := os.UserHomeDir()
+ if err == nil {
+ path = strings.ReplaceAll(path, homeDir, "~")
+ }
+ return path
+}
+
func prettifyToolName(name string) string {
switch name {
case agent.AgentToolName:
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index 33d711f3941af28c233242d4e4f94783358d1031..fa9de764ee20a90b4680e8495eb57bc64e028f4c 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -40,7 +40,7 @@ type toolCallCmp struct {
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
+ 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
@@ -86,7 +86,7 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
m := &toolCallCmp{
call: tc,
- parentMessageId: parentMessageId,
+ parentMessageID: parentMessageId,
}
for _, opt := range opts {
opt(m)
@@ -140,7 +140,7 @@ func (m *toolCallCmp) View() tea.View {
if m.isNested {
return tea.NewView(box.Render(m.renderPending()))
}
- return tea.NewView(box.PaddingLeft(1).Render(m.renderPending()))
+ return tea.NewView(box.Render(m.renderPending()))
}
r := registry.lookup(m.call.Name)
@@ -148,7 +148,7 @@ func (m *toolCallCmp) View() tea.View {
if m.isNested {
return tea.NewView(box.Render(r.Render(m)))
}
- return tea.NewView(box.PaddingLeft(1).Render(r.Render(m)))
+ return tea.NewView(box.Render(r.Render(m)))
}
// State management methods
@@ -168,7 +168,7 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
// ParentMessageId returns the ID of the message that initiated this tool call
func (m *toolCallCmp) ParentMessageId() string {
- return m.parentMessageId
+ return m.parentMessageID
}
// SetToolResult updates the tool result and stops the spinning animation
@@ -209,30 +209,24 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
// renderPending displays the tool name with a loading animation for pending tool calls
func (m *toolCallCmp) renderPending() string {
- return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
+ 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
- }
- borderStyle := lipgloss.NormalBorder()
- if m.focused {
- borderStyle = lipgloss.DoubleBorder()
- }
- return t.S().Muted.
- BorderLeft(true).
- BorderForeground(t.Border).
- BorderStyle(borderStyle)
+
+ 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 - 2 // take into account the border and PaddingLeft
+ return m.width - 5 // take into account the border and PaddingLeft
}
// fit truncates content to fit within the specified width with ellipsis
diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go
index 23035c4030542b6a157a3dd08448ea4271d095d6..46b6cf2b01d67e097799de0df11c34b3efa436f6 100644
--- a/internal/tui/components/core/list/keys.go
+++ b/internal/tui/components/core/list/keys.go
@@ -33,22 +33,22 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("k"),
),
UpOneItem: key.NewBinding(
- key.WithKeys("shift+up"),
+ key.WithKeys("shift+up", "shift+k"),
),
DownOneItem: key.NewBinding(
- key.WithKeys("shift+down"),
+ key.WithKeys("shift+down", "shift+j"),
),
HalfPageDown: key.NewBinding(
- key.WithKeys("ctrl+d"),
+ key.WithKeys("d"),
),
HalfPageUp: key.NewBinding(
- key.WithKeys("ctrl+u"),
+ key.WithKeys("u"),
),
Home: key.NewBinding(
- key.WithKeys("ctrl+g", "home"),
+ key.WithKeys("g", "home"),
),
End: key.NewBinding(
- key.WithKeys("ctrl+shift+g", "end"),
+ key.WithKeys("shift+g", "end"),
),
}
}
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 247672e53d76e931e04dd74c3f7d4690f7162cd9..cfbcc89033d49dd7cdb8af32351dd1cedcbe27ec 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -40,6 +40,7 @@ type ListModel interface {
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
+ ClearSelection() tea.Cmd // Clear the current selection
Filter(string) tea.Cmd // Filter items based on a search term
}
@@ -1332,3 +1333,15 @@ func (m *model) SetSelected(index int) tea.Cmd {
}
return tea.Batch(cmds...)
}
+
+// ClearSelection clears the current selection and focus.
+func (m *model) ClearSelection() tea.Cmd {
+ cmds := []tea.Cmd{}
+ if m.selectionState.selectedIndex >= 0 && m.selectionState.selectedIndex < len(m.filteredItems) {
+ if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
+ cmds = append(cmds, i.Blur())
+ }
+ }
+ m.selectionState.selectedIndex = NoSelection
+ return tea.Batch(cmds...)
+}
diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go
index 59f43dca6d995255688268f7859bce774b49aad5..2b02442437918adbc675bd3ff01b5e5cd71902b7 100644
--- a/internal/tui/styles/icons.go
+++ b/internal/tui/styles/icons.go
@@ -2,11 +2,16 @@ package styles
const (
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/theme.go b/internal/tui/styles/theme.go
index 099cef8ef957ee2e45c931bb6323e2630f9f74ce..0bfcc0388c4da1336863445a9524284569c35722 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -199,11 +199,11 @@ func (t *Theme) buildStyles() *Styles {
Markdown: ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
- BlockPrefix: "\n",
- BlockSuffix: "\n",
- Color: stringPtr("252"),
+ // BlockPrefix: "\n",
+ // BlockSuffix: "\n",
+ Color: stringPtr("252"),
},
- Margin: uintPtr(defaultMargin),
+ // Margin: uintPtr(defaultMargin),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{},
From c03817c18ce172acf7dac9edf8f8dc98fe2e16e7 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 11:32:25 +0200
Subject: [PATCH 51/73] small fixes
---
internal/llm/models/local.go | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/internal/llm/models/local.go b/internal/llm/models/local.go
index 5d8412c86a0f3f4ccf305763171f6acfdaea6eb1..6ff8391b48acf2d8553631b7a15ce9b758d0b480 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"
@@ -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,
)
}
From 509d39efa096dc161ade2b492e461e5e6f28226c Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Sat, 7 Jun 2025 08:54:35 -0400
Subject: [PATCH 52/73] perf(anim): use faster PRNG
---
internal/tui/components/anim/anim.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index 46c4156b02d148f51e8ff03afd7341354de1ad44..18585f1a82a8dadc916b0af69ad4c1f067286bf4 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -3,7 +3,7 @@ package anim
import (
"fmt"
"image/color"
- "math/rand"
+ "math/rand/v2"
"strings"
"time"
@@ -41,7 +41,7 @@ type cyclingChar struct {
}
func (c cyclingChar) randomRune() rune {
- return (charRunes)[rand.Intn(len(charRunes))] //nolint:gosec
+ return (charRunes)[rand.IntN(len(charRunes))] //nolint:gosec
}
func (c cyclingChar) state(start time.Time) charState {
@@ -137,7 +137,7 @@ func New(cyclingCharsSize uint, label string, opts ...animOption) Animation {
}
makeDelay := func(a int32, b time.Duration) time.Duration {
- return time.Duration(rand.Int31n(a)) * (time.Millisecond * b) //nolint:gosec
+ return time.Duration(rand.Int32N(a)) * (time.Millisecond * b) //nolint:gosec
}
makeInitialDelay := func() time.Duration {
From 301d42f7197944baf83c1d8dd90beb3328479006 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Sat, 7 Jun 2025 08:56:08 -0400
Subject: [PATCH 53/73] perf(anim): eliminate string concatenation
---
internal/tui/components/anim/anim.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index 18585f1a82a8dadc916b0af69ad4c1f067286bf4..016165313008e5d46875de3f02a9ea0dde016894 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -258,7 +258,7 @@ func (a anim) View() tea.View {
textStyle.Render(string(c.currentValue)),
)
}
- return tea.NewView(b.String() + textStyle.Render(a.ellipsis.View()))
+ b.WriteString(textStyle.Render(a.ellipsis.View()))
}
return tea.NewView(b.String())
From c519c8ed1071f3b209eef4e95d2291c44386ff57 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Sat, 7 Jun 2025 08:57:53 -0400
Subject: [PATCH 54/73] perf(anim): reduce pointer dereferences
---
internal/tui/components/anim/anim.go | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index 016165313008e5d46875de3f02a9ea0dde016894..3a7b615f6b09a7b8c3a6fac5909cccc6eccb4bb3 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -226,14 +226,15 @@ func (a anim) ID() string {
}
func (a *anim) updateChars(chars *[]cyclingChar) {
- for i, c := range *chars {
+ charSlice := *chars // dereference to avoid repeated pointer access
+ for i, c := range charSlice {
switch c.state(a.start) {
case charInitialState:
- (*chars)[i].currentValue = '.'
+ charSlice[i].currentValue = '.'
case charCyclingState:
- (*chars)[i].currentValue = c.randomRune()
+ charSlice[i].currentValue = c.randomRune()
case charEndOfLifeState:
- (*chars)[i].currentValue = c.finalValue
+ charSlice[i].currentValue = c.finalValue
}
}
}
From cec27925c596a9e04d2e1674b4893e853f87efa1 Mon Sep 17 00:00:00 2001
From: Christian Rocha
Date: Sat, 7 Jun 2025 09:00:33 -0400
Subject: [PATCH 55/73] perf(anim): preallocate strings.Builder capacities
---
internal/tui/components/anim/anim.go | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go
index 3a7b615f6b09a7b8c3a6fac5909cccc6eccb4bb3..c39de0d899a1b4eaf3896ea32b02883374af1195 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -241,8 +241,19 @@ func (a *anim) updateChars(chars *[]cyclingChar) {
// View renders the animation.
func (a anim) View() tea.View {
- t := styles.CurrentTheme()
- var b strings.Builder
+ 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 {
From 6188eff8311e51dc2ac4de357fa8a95cc991d2ca Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 15:08:25 +0200
Subject: [PATCH 56/73] wip filepicker
---
diff.diff | 124 ---------------
go.mod | 10 +-
go.sum | 23 +--
.../dialogs/filepicker/filepicker.go | 141 ++++++++++++++++++
.../tui/components/dialogs/filepicker/keys.go | 73 +++++++++
internal/tui/keys.go | 15 +-
internal/tui/page/chat/chat.go | 19 ++-
internal/tui/page/chat/keys.go | 5 +
internal/tui/styles/theme.go | 18 +++
internal/tui/tui.go | 17 +++
10 files changed, 278 insertions(+), 167 deletions(-)
delete mode 100644 diff.diff
create mode 100644 internal/tui/components/dialogs/filepicker/filepicker.go
create mode 100644 internal/tui/components/dialogs/filepicker/keys.go
diff --git a/diff.diff b/diff.diff
deleted file mode 100644
index e22ae61ef5e96692b9e0d5dbf4b1ad1b7ea578b0..0000000000000000000000000000000000000000
--- a/diff.diff
+++ /dev/null
@@ -1,124 +0,0 @@
-diff --git a/.opencode.json b/.opencode.json
-index 75e357d..59be1e8 100644
---- a/.opencode.json
-+++ b/.opencode.json
-@@ -6,6 +6,6 @@
- }
- },
- "tui": {
-- "theme": "opencode-dark"
-+ "theme": "charm"
- }
- }
-diff --git a/go.mod b/go.mod
-index 18ad042..940a8e8 100644
---- a/go.mod
-+++ b/go.mod
-@@ -36,6 +36,8 @@ require (
- github.com/stretchr/testify v1.10.0
- )
-
-+require github.com/charmbracelet/x/exp/charmtone v0.0.0-20250530202730-6ba1785cd7b9 // indirect
-+
- require (
- cloud.google.com/go v0.116.0 // indirect
- cloud.google.com/go/auth v0.13.0 // indirect
-diff --git a/go.sum b/go.sum
-index f6e08b7..8f347ed 100644
---- a/go.sum
-+++ b/go.sum
-@@ -84,6 +84,8 @@ github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa h1:JU05TLAB
- github.com/charmbracelet/x/ansi v0.9.3-0.20250516160309-24eee56f89fa/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=
-diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
-index 2ee0b04..52c4dae 100644
---- a/internal/tui/components/chat/chat.go
-+++ b/internal/tui/components/chat/chat.go
-@@ -95,7 +95,7 @@ func lspsConfigured() string {
- func logoBlock() string {
- t := theme.CurrentTheme()
- return logo.Render(version.Version, true, logo.Opts{
-- FieldColor: t.Accent(),
-+ FieldColor: t.Secondary(),
- TitleColorA: t.Primary(),
- TitleColorB: t.Secondary(),
- CharmColor: t.Primary(),
-diff --git a/internal/tui/tui.go b/internal/tui/tui.go
-index 9e8a62a..3f07956 100644
---- a/internal/tui/tui.go
-+++ b/internal/tui/tui.go
-@@ -18,6 +18,7 @@ import (
- "github.com/opencode-ai/opencode/internal/tui/util"
- )
-
-+// appModel represents the main application model that manages pages, dialogs, and UI state.
- type appModel struct {
- width, height int
- keyMap KeyMap
-@@ -35,6 +36,7 @@ type appModel struct {
- completions completions.Completions
- }
-
-+// 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()
-@@ -46,6 +48,7 @@ func (a appModel) Init() tea.Cmd {
- return tea.Batch(cmds...)
- }
-
-+// 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
-@@ -111,6 +114,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- return a, tea.Batch(cmds...)
- }
-
-+// handleWindowResize processes window resize events and updates all components.
- func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
- var cmds []tea.Cmd
- msg.Height -= 1 // Make space for the status bar
-@@ -134,6 +138,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
- return tea.Batch(cmds...)
- }
-
-+// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
- func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
- switch {
- // completions
-@@ -182,11 +187,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
- }
- }
-
--// RegisterCommand adds a command to the command dialog
--// func (a *appModel) RegisterCommand(cmd dialog.Command) {
--// a.commands = append(a.commands, cmd)
--// }
--
-+// 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
-@@ -209,6 +210,7 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
- return tea.Batch(cmds...)
- }
-
-+// 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{
-@@ -252,6 +254,7 @@ func (a *appModel) View() tea.View {
- return view
- }
-
-+// New creates and initializes a new TUI application model.
- func New(app *app.App) tea.Model {
- startPage := page.ChatPage
- model := &appModel{
diff --git a/go.mod b/go.mod
index 560577ca7c5d2eff0384833aecf76e6f327c4102..c4892e8b3616ed276178f7a4c32db9dae48a3439 100644
--- a/go.mod
+++ b/go.mod
@@ -11,9 +11,8 @@ require (
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/charlievieth/fastwalk v1.0.11
- github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f
+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250602154956-43689cfc0174
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
@@ -23,9 +22,6 @@ require (
github.com/go-logfmt/logfmt v0.6.0
github.com/google/uuid v1.6.0
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/openai/openai-go v0.1.0-beta.2
github.com/pressly/goose/v3 v3.24.2
@@ -37,6 +33,8 @@ require (
github.com/stretchr/testify v1.10.0
)
+require github.com/dustin/go-humanize v1.0.1 // indirect
+
require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
@@ -60,7 +58,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/aws/smithy-go v1.20.3 // indirect
- github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
@@ -85,7 +82,6 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
- github.com/mattn/go-isatty v0.0.20 // 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
diff --git a/go.sum b/go.sum
index 2c9413322f0bc1043b97f4e238f05cf494b8941a..45bfc062f74dc87fd689169a910d2d3cf151b59f 100644
--- a/go.sum
+++ b/go.sum
@@ -58,24 +58,18 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudr
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
-github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
-github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
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/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.20250526131538-b3f0c9e42318 h1:f8Q0ybZGxT+St1JfPM7yoz/XFpbmtodcIehaom/9XT8=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250526131538-b3f0c9e42318/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e h1:+3I/1v7vbN0Vf8Tjm3Q0zdLQqjOM/TjQBvoRDQtoAss=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603122936-f1a3fad2b64e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f h1:vvNB+i59Wp3L6gYcpuhfAdNjr4/e6qM/st3ySWfmZnU=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250603125125-87aee03b3d4f/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607105053-36addcd3ab8c h1:Dgy7cOR3skvJjGVnhyaixW6ugxVxLtSjRCiMRSdbXSc=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607105053-36addcd3ab8c/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+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/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
@@ -162,21 +156,14 @@ github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi9
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-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=
github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -196,7 +183,6 @@ 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=
@@ -312,7 +298,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
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=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go
new file mode 100644
index 0000000000000000000000000000000000000000..d80031c6115e6aaed7b071406c129ac7ceecc233
--- /dev/null
+++ b/internal/tui/components/dialogs/filepicker/filepicker.go
@@ -0,0 +1,141 @@
+package filepicker
+
+import (
+ "os"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/v2/filepicker"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/opencode-ai/opencode/internal/tui/components/core"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
+)
+
+const (
+ maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
+ FilePickerID = "filepicker"
+ fileSelectionHight = 10
+)
+
+type FilePicker interface {
+ dialogs.DialogModel
+}
+
+type filePicker struct {
+ wWidth int
+ wHeight int
+ width int
+ filepicker filepicker.Model
+ selectedFile string
+}
+
+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)
+
+ return &filePicker{filepicker: fp}
+}
+
+func (m *filePicker) Init() tea.Cmd {
+ return m.filepicker.Init()
+}
+
+func (m *filePicker) 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
+ }
+
+ var cmd tea.Cmd
+ m.filepicker, cmd = m.filepicker.Update(msg)
+
+ // Did the user select a file?
+ if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
+ // Get the path of the selected file.
+ m.selectedFile = path
+ }
+
+ return m, cmd
+}
+
+func (m *filePicker) 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(),
+ )
+ return tea.NewView(m.style().Render(content))
+}
+
+func (m *filePicker) currentImage() string {
+ for _, ext := range m.filepicker.AllowedTypes {
+ if strings.HasSuffix(m.filepicker.HighlightedPath(), ext) {
+ return m.filepicker.HighlightedPath()
+ }
+ }
+ return ""
+}
+
+func (m *filePicker) imagePreview() string {
+ if m.currentImage() == "" {
+ return m.imagePreviewStyle().Render()
+ }
+
+ return ""
+}
+
+func (m *filePicker) imagePreviewStyle() lipgloss.Style {
+ t := styles.CurrentTheme()
+ w, h := m.imagePreviewSize()
+ return t.S().Base.
+ Width(w).
+ Height(h).
+ Margin(1).
+ Background(t.BgOverlay)
+}
+
+func (m *filePicker) imagePreviewSize() (int, int) {
+ return m.width - 4, min(20, m.wHeight/2)
+}
+
+func (m *filePicker) style() lipgloss.Style {
+ t := styles.CurrentTheme()
+ return t.S().Base.
+ Width(m.width).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(t.BorderFocus)
+}
+
+// ID implements FilePicker.
+func (m *filePicker) ID() dialogs.DialogID {
+ return FilePickerID
+}
+
+// Position implements FilePicker.
+func (m *filePicker) 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
+}
diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go
new file mode 100644
index 0000000000000000000000000000000000000000..d5f3a971808ff30e1503e1c4654944b867fd90b0
--- /dev/null
+++ b/internal/tui/components/dialogs/filepicker/keys.go
@@ -0,0 +1,73 @@
+package filepicker
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+// KeyMap defines keyboard bindings for dialog management.
+type KeyMap struct {
+ Select,
+ Down,
+ Up,
+ Forward,
+ Backward,
+ InsertCWD,
+ 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"),
+ ),
+ InsertCWD: key.NewBinding(
+ key.WithKeys("i"),
+ key.WithHelp("i", "manual path input"),
+ ),
+ 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{
+ k.InsertCWD,
+ key.NewBinding(
+ key.WithHelp("↑↓←→", "navigate"),
+ ),
+ k.Select,
+ k.Close,
+ }
+}
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index 96dbab01400f622e8d3e224e3f626f206d4ab68f..4a9d0f81d600e8fb5701cc8555241723b2188d74 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -6,12 +6,11 @@ import (
)
type KeyMap struct {
- Logs key.Binding
- Quit key.Binding
- Help key.Binding
- Commands key.Binding
- Sessions key.Binding
- FilePicker key.Binding
+ Logs key.Binding
+ Quit key.Binding
+ Help key.Binding
+ Commands key.Binding
+ Sessions key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -37,10 +36,6 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("ctrl+s"),
key.WithHelp("ctrl+s", "sessions"),
),
- FilePicker: key.NewBinding(
- key.WithKeys("ctrl+f"),
- key.WithHelp("ctrl+f", "select files to upload"),
- ),
}
}
diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go
index b62dba2c9d62eb107e5c2eb06bd5c23b5e1bbd23..ce5a38a3454d26c77cb5ceb209cd7d41ac216b23 100644
--- a/internal/tui/page/chat/chat.go
+++ b/internal/tui/page/chat/chat.go
@@ -24,17 +24,20 @@ type ChatFocusedMsg struct {
Focused bool // True if the chat input is focused, false otherwise
}
-type chatPage struct {
- app *app.App
+type (
+ OpenFilePickerMsg struct{}
+ chatPage struct {
+ app *app.App
- layout layout.SplitPaneLayout
+ layout layout.SplitPaneLayout
- session session.Session
+ session session.Session
- keyMap KeyMap
+ keyMap KeyMap
- chatFocused bool
-}
+ chatFocused bool
+ }
+)
func (p *chatPage) Init() tea.Cmd {
cmd := p.layout.Init()
@@ -83,6 +86,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
util.CmdHandler(chat.SessionClearedMsg{}),
)
+ case key.Matches(msg, p.keyMap.FilePicker):
+ return p, util.CmdHandler(OpenFilePickerMsg{})
case key.Matches(msg, p.keyMap.Tab):
logging.Info("Tab key pressed, toggling chat focus")
if p.session.ID == "" {
diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go
index d8b151dbd9b3a3f0c20db1f16e51f011c25c4e7f..8441e23b02fd16c70d80ad6258633b3e9756d885 100644
--- a/internal/tui/page/chat/keys.go
+++ b/internal/tui/page/chat/keys.go
@@ -7,6 +7,7 @@ import (
type KeyMap struct {
NewSession key.Binding
+ FilePicker key.Binding
Cancel key.Binding
Tab key.Binding
}
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("tab"),
key.WithHelp("tab", "change focus"),
),
+ FilePicker: key.NewBinding(
+ key.WithKeys("ctrl+f"),
+ key.WithHelp("ctrl+f", "select files to upload"),
+ ),
}
}
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
index 0bfcc0388c4da1336863445a9524284569c35722..79a56959ca178f34ad974777e095e91285380911 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -5,6 +5,7 @@ import (
"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"
@@ -110,6 +111,9 @@ type Styles struct {
// Diff
Diff Diff
+
+ // FilePicker
+ FilePicker filepicker.Styles
}
func (t *Theme) S() *Styles {
@@ -430,6 +434,20 @@ func (t *Theme) buildStyles() *Styles {
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"),
+ },
}
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 71e0ea7dc64b0a1e2ffa094564f8287e440f6452..2477fb7022b3904875d3e4dc31467b9510273dcb 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -14,6 +14,7 @@ import (
"github.com/opencode-ai/opencode/internal/tui/components/core/status"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/filepicker"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/models"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/sessions"
@@ -125,12 +126,22 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
}
}
+
case commands.SwitchModelMsg:
return a, util.CmdHandler(
dialogs.OpenDialogMsg{
Model: models.NewModelDialogCmp(),
},
)
+ // 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(),
+ })
case tea.KeyPressMsg:
return a, a.handleKeyPressMsg(msg)
}
@@ -138,6 +149,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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...)
}
@@ -277,6 +293,7 @@ func (a *appModel) View() tea.View {
lipgloss.NewLayer(appView),
}
if a.dialog.HasDialogs() {
+ logging.Info("Rendering dialogs")
layers = append(
layers,
a.dialog.GetLayers()...,
From 15d195a005a12ec111aa78437184ab57fe0c95a6 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 16:54:09 +0200
Subject: [PATCH 57/73] feat: add new filepicker
---
go.mod | 12 +-
go.sum | 17 +-
internal/tui/components/chat/editor/editor.go | 15 +-
internal/tui/components/dialog/filepicker.go | 932 +++++++++---------
.../dialogs/filepicker/filepicker.go | 47 +-
internal/tui/components/image/image.go | 89 ++
internal/tui/components/image/load.go | 157 +++
internal/tui/image/images.go | 73 --
8 files changed, 776 insertions(+), 566 deletions(-)
create mode 100644 internal/tui/components/image/image.go
create mode 100644 internal/tui/components/image/load.go
delete mode 100644 internal/tui/image/images.go
diff --git a/go.mod b/go.mod
index c4892e8b3616ed276178f7a4c32db9dae48a3439..edb43f7075b9959941ac398c85fafb9d6d6e4610 100644
--- a/go.mod
+++ b/go.mod
@@ -18,11 +18,14 @@ require (
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/mark3labs/mcp-go v0.17.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
@@ -30,10 +33,17 @@ require (
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/dustin/go-humanize v1.0.1 // indirect
+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
diff --git a/go.sum b/go.sum
index 45bfc062f74dc87fd689169a910d2d3cf151b59f..0811dc5e595582b364af04758a219f2cbe634441 100644
--- a/go.sum
+++ b/go.sum
@@ -58,6 +58,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudr
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -66,8 +68,6 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
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.20250607105053-36addcd3ab8c h1:Dgy7cOR3skvJjGVnhyaixW6ugxVxLtSjRCiMRSdbXSc=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607105053-36addcd3ab8c/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
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=
@@ -98,6 +98,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
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/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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
@@ -164,12 +168,16 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
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/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=
github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
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=
@@ -215,6 +223,10 @@ 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=
@@ -298,6 +310,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
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=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index 096e6c5e97ce8d6f2a3fc243fe44fa095a858f11..aae5d3ec5b0c51c99f328ebb489af06c537adbe8 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -19,7 +19,6 @@ import (
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/completions"
- "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/util"
@@ -142,13 +141,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
- 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)
+ // return m, nil
case completions.CompletionsClosedMsg:
m.isCompletionsOpen = false
m.currentQuery = ""
diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go
index fdb23f4d3afafcd9a548eb254afd076fe22212d9..85c946b79dc5a55b1031a17138c3e4bcf4136131 100644
--- a/internal/tui/components/dialog/filepicker.go
+++ b/internal/tui/components/dialog/filepicker.go
@@ -1,468 +1,468 @@
package dialog
-import (
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textinput"
- "github.com/charmbracelet/bubbles/v2/viewport"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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/tui/image"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "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.SetWidth(80)
- f.viewport.SetHeight(22)
- f.cursor = 0
- f.getCurrentFileBelowCursor()
- case tea.KeyPressMsg:
- 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() tea.View {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base
- 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 := t.S().Text.Width(adjustedWidth)
-
- if i == f.cursor {
- itemStyle = itemStyle.
- Background(t.Primary).
- 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, baseStyle.Width(adjustedWidth).Render(""))
- }
-
- currentPath := baseStyle.
- Height(1).
- Width(adjustedWidth).
- Render(f.cwd.View())
-
- viewportstyle := baseStyle.
- Width(f.viewport.Width()).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus).
- 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,
- baseStyle.Width(adjustedWidth).Render(""),
- baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
- baseStyle.Width(adjustedWidth).Render(""),
- t.S().Muted.Width(adjustedWidth).Render(insertExitText),
- )
-
- f.cwd.SetValue(f.cwd.Value())
- contentStyle := baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus).
- Width(lipgloss.Width(content) + 4)
-
- return tea.NewView(
- lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
- )
-}
-
-type FilepickerCmp interface {
- util.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()
- currentDirectory := textinput.New()
- currentDirectory.CharLimit = 200
- currentDirectory.SetWidth(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")
-}
+// import (
+// "fmt"
+// "net/http"
+// "os"
+// "path/filepath"
+// "sort"
+// "strings"
+// "time"
+//
+// "github.com/charmbracelet/bubbles/v2/key"
+// "github.com/charmbracelet/bubbles/v2/textinput"
+// "github.com/charmbracelet/bubbles/v2/viewport"
+// tea "github.com/charmbracelet/bubbletea/v2"
+// "github.com/charmbracelet/lipgloss/v2"
+// "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/tui/image"
+// "github.com/opencode-ai/opencode/internal/tui/styles"
+// "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.SetWidth(80)
+// f.viewport.SetHeight(22)
+// f.cursor = 0
+// f.getCurrentFileBelowCursor()
+// case tea.KeyPressMsg:
+// 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() tea.View {
+// t := styles.CurrentTheme()
+// baseStyle := t.S().Base
+// 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 := t.S().Text.Width(adjustedWidth)
+//
+// if i == f.cursor {
+// itemStyle = itemStyle.
+// Background(t.Primary).
+// 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, baseStyle.Width(adjustedWidth).Render(""))
+// }
+//
+// currentPath := baseStyle.
+// Height(1).
+// Width(adjustedWidth).
+// Render(f.cwd.View())
+//
+// viewportstyle := baseStyle.
+// Width(f.viewport.Width()).
+// Border(lipgloss.RoundedBorder()).
+// BorderForeground(t.BorderFocus).
+// 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,
+// baseStyle.Width(adjustedWidth).Render(""),
+// baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
+// baseStyle.Width(adjustedWidth).Render(""),
+// t.S().Muted.Width(adjustedWidth).Render(insertExitText),
+// )
+//
+// f.cwd.SetValue(f.cwd.Value())
+// contentStyle := baseStyle.Padding(1, 2).
+// Border(lipgloss.RoundedBorder()).
+// BorderForeground(t.BorderFocus).
+// Width(lipgloss.Width(content) + 4)
+//
+// return tea.NewView(
+// lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
+// )
+// }
+//
+// type FilepickerCmp interface {
+// util.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()
+// currentDirectory := textinput.New()
+// currentDirectory.CharLimit = 200
+// currentDirectory.SetWidth(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/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go
index d80031c6115e6aaed7b071406c129ac7ceecc233..cf1949b3c1fc0ec8020ccad0a65fee2fd58b9fce 100644
--- a/internal/tui/components/dialogs/filepicker/filepicker.go
+++ b/internal/tui/components/dialogs/filepicker/filepicker.go
@@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
+ "github.com/opencode-ai/opencode/internal/tui/components/image"
"github.com/opencode-ai/opencode/internal/tui/styles"
)
@@ -23,11 +24,13 @@ type FilePicker interface {
}
type filePicker struct {
- wWidth int
- wHeight int
- width int
- filepicker filepicker.Model
- selectedFile string
+ wWidth int
+ wHeight int
+ width int
+ filepicker filepicker.Model
+ selectedFile string
+ highlightedFile string
+ image image.Model
}
func NewFilePickerCmp() FilePicker {
@@ -42,7 +45,8 @@ func NewFilePickerCmp() FilePicker {
fp.Cursor = ""
fp.SetHeight(fileSelectionHight)
- return &filePicker{filepicker: fp}
+ image := image.New(1, 1, "")
+ return &filePicker{filepicker: fp, image: image}
}
func (m *filePicker) Init() tea.Cmd {
@@ -65,15 +69,24 @@ func (m *filePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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.
m.selectedFile = path
}
-
- return m, cmd
+ m.image, cmd = m.image.Update(msg)
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
}
func (m *filePicker) View() tea.View {
@@ -98,21 +111,23 @@ func (m *filePicker) currentImage() string {
}
func (m *filePicker) imagePreview() string {
+ t := styles.CurrentTheme()
+ w, h := m.imagePreviewSize()
if m.currentImage() == "" {
- return m.imagePreviewStyle().Render()
+ imgPreview := t.S().Base.
+ Width(w).
+ Height(h).
+ Background(t.BgOverlay)
+
+ return m.imagePreviewStyle().Render(imgPreview.Render())
}
- return ""
+ return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
}
func (m *filePicker) imagePreviewStyle() lipgloss.Style {
t := styles.CurrentTheme()
- w, h := m.imagePreviewSize()
- return t.S().Base.
- Width(w).
- Height(h).
- Margin(1).
- Background(t.BgOverlay)
+ return t.S().Base.Padding(1, 1, 1, 1)
}
func (m *filePicker) imagePreviewSize() (int, int) {
diff --git a/internal/tui/components/image/image.go b/internal/tui/components/image/image.go
new file mode 100644
index 0000000000000000000000000000000000000000..d1dbfea85c825a66c4543e152511a767c874f838
--- /dev/null
+++ b/internal/tui/components/image/image.go
@@ -0,0 +1,89 @@
+// Based on the implementation by @trashhalo at:
+// https://github.com/trashhalo/imgcat
+package image
+
+import (
+ "context"
+ "fmt"
+ _ "image/jpeg"
+ _ "image/png"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+)
+
+type Model struct {
+ url string
+ image string
+ width uint
+ height uint
+ err error
+
+ cancelAnimation context.CancelFunc
+}
+
+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 rewdrawMsg:
+ 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 rewdrawMsg{
+ width: width,
+ height: height,
+ url: url,
+ }
+ }
+}
+
+func (m Model) UpdateUrl(url string) tea.Cmd {
+ return func() tea.Msg {
+ return rewdrawMsg{
+ width: m.width,
+ height: m.height,
+ url: url,
+ }
+ }
+}
+
+type rewdrawMsg 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..f6015b8e2725bf3a5380eef11357e1b779bba62f
--- /dev/null
+++ b/internal/tui/components/image/load.go
@@ -0,0 +1,157 @@
+// Based on the implementation by @trashhalo at:
+// https://github.com/trashhalo/imgcat
+package image
+
+import (
+ "image"
+ "image/png"
+ "io"
+ "io/ioutil"
+ "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.Response
+ resp, err = http.Get(url)
+ 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) {
+ if m.cancelAnimation != nil {
+ m.cancelAnimation()
+ }
+
+ // blank out image so it says "loading..."
+ m.image = ""
+
+ return handleLoadMsgStatic(m, msg)
+}
+
+func handleLoadMsgStatic(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, url string, 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 := 0; x < w; x++ {
+ 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, url, r)
+ }
+
+ img, _, err := imageorient.Decode(r)
+ if err != nil {
+ return "", err
+ }
+
+ return imageToString(width, height, url, img)
+}
+
+func svgToimage(width uint, height uint, url string, 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 := ioutil.TempFile("", "imgcat.*.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, url, img)
+}
diff --git a/internal/tui/image/images.go b/internal/tui/image/images.go
deleted file mode 100644
index 72ce2b38f069deff64a367dc89003489d92a498c..0000000000000000000000000000000000000000
--- a/internal/tui/image/images.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package image
-
-import (
- "fmt"
- "image"
- "image/color"
- "os"
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- "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 color.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
-}
From 597fd7fae3b5bfb1d015b814e7d1e36b2e7f461f Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 17:15:31 +0200
Subject: [PATCH 58/73] chore: cleanup image component
---
cspell.json | 2 +-
internal/tui/components/dialog/filepicker.go | 468 ------------------
.../dialogs/filepicker/filepicker.go | 65 ++-
internal/tui/components/image/image.go | 15 +-
internal/tui/components/image/load.go | 32 +-
5 files changed, 58 insertions(+), 524 deletions(-)
delete mode 100644 internal/tui/components/dialog/filepicker.go
diff --git a/cspell.json b/cspell.json
index 7a440d8fbdf07a8d8274c707710d1d930dabe787..f59940e21add71cde463dbf18e50d40ff0c76594 100644
--- a/cspell.json
+++ b/cspell.json
@@ -1 +1 @@
-{"words":["opencode","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph"],"version":"0.2","language":"en","flagWords":[]}
\ No newline at end of file
+{"flagWords":[],"language":"en","words":["opencode","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/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go
deleted file mode 100644
index 85c946b79dc5a55b1031a17138c3e4bcf4136131..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/filepicker.go
+++ /dev/null
@@ -1,468 +0,0 @@
-package dialog
-
-// import (
-// "fmt"
-// "net/http"
-// "os"
-// "path/filepath"
-// "sort"
-// "strings"
-// "time"
-//
-// "github.com/charmbracelet/bubbles/v2/key"
-// "github.com/charmbracelet/bubbles/v2/textinput"
-// "github.com/charmbracelet/bubbles/v2/viewport"
-// tea "github.com/charmbracelet/bubbletea/v2"
-// "github.com/charmbracelet/lipgloss/v2"
-// "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/tui/image"
-// "github.com/opencode-ai/opencode/internal/tui/styles"
-// "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.SetWidth(80)
-// f.viewport.SetHeight(22)
-// f.cursor = 0
-// f.getCurrentFileBelowCursor()
-// case tea.KeyPressMsg:
-// 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() tea.View {
-// t := styles.CurrentTheme()
-// baseStyle := t.S().Base
-// 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 := t.S().Text.Width(adjustedWidth)
-//
-// if i == f.cursor {
-// itemStyle = itemStyle.
-// Background(t.Primary).
-// 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, baseStyle.Width(adjustedWidth).Render(""))
-// }
-//
-// currentPath := baseStyle.
-// Height(1).
-// Width(adjustedWidth).
-// Render(f.cwd.View())
-//
-// viewportstyle := baseStyle.
-// Width(f.viewport.Width()).
-// Border(lipgloss.RoundedBorder()).
-// BorderForeground(t.BorderFocus).
-// 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,
-// baseStyle.Width(adjustedWidth).Render(""),
-// baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
-// baseStyle.Width(adjustedWidth).Render(""),
-// t.S().Muted.Width(adjustedWidth).Render(insertExitText),
-// )
-//
-// f.cwd.SetValue(f.cwd.Value())
-// contentStyle := baseStyle.Padding(1, 2).
-// Border(lipgloss.RoundedBorder()).
-// BorderForeground(t.BorderFocus).
-// Width(lipgloss.Width(content) + 4)
-//
-// return tea.NewView(
-// lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
-// )
-// }
-//
-// type FilepickerCmp interface {
-// util.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()
-// currentDirectory := textinput.New()
-// currentDirectory.CharLimit = 200
-// currentDirectory.SetWidth(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/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go
index cf1949b3c1fc0ec8020ccad0a65fee2fd58b9fce..c3bda21e1577e47ec7b679eb958c70c5160e13a0 100644
--- a/internal/tui/components/dialogs/filepicker/filepicker.go
+++ b/internal/tui/components/dialogs/filepicker/filepicker.go
@@ -5,12 +5,14 @@ import (
"strings"
"github.com/charmbracelet/bubbles/v2/filepicker"
+ "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/components/image"
"github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/opencode-ai/opencode/internal/tui/util"
)
const (
@@ -19,16 +21,19 @@ const (
fileSelectionHight = 10
)
+type FilePickedMsg struct {
+ FilePath string
+}
+
type FilePicker interface {
dialogs.DialogModel
}
-type filePicker struct {
+type model struct {
wWidth int
wHeight int
width int
- filepicker filepicker.Model
- selectedFile string
+ filePicker filepicker.Model
highlightedFile string
image image.Model
}
@@ -46,31 +51,40 @@ func NewFilePickerCmp() FilePicker {
fp.SetHeight(fileSelectionHight)
image := image.New(1, 1, "")
- return &filePicker{filepicker: fp, image: image}
+ return &model{filePicker: fp, image: image}
}
-func (m *filePicker) Init() tea.Cmd {
- return m.filepicker.Init()
+func (m *model) Init() tea.Cmd {
+ return m.filePicker.Init()
}
-func (m *filePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+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 := 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
+ m.filePicker.Styles = styles
return m, nil
+
+ case tea.KeyPressMsg:
+ 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)
+ m.filePicker, cmd = m.filePicker.Update(msg)
cmds = append(cmds, cmd)
if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
w, h := m.imagePreviewSize()
@@ -80,37 +94,40 @@ func (m *filePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.highlightedFile = m.currentImage()
// Did the user select a file?
- if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
+ if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
// Get the path of the selected file.
- m.selectedFile = path
+ return m, tea.Sequence(
+ util.CmdHandler(dialogs.CloseDialogMsg{}),
+ util.CmdHandler(FilePickedMsg{FilePath: path}),
+ )
}
m.image, cmd = m.image.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
-func (m *filePicker) View() tea.View {
+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(),
+ m.filePicker.View(),
)
return tea.NewView(m.style().Render(content))
}
-func (m *filePicker) currentImage() string {
- for _, ext := range m.filepicker.AllowedTypes {
- if strings.HasSuffix(m.filepicker.HighlightedPath(), ext) {
- return m.filepicker.HighlightedPath()
+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 *filePicker) imagePreview() string {
+func (m *model) imagePreview() string {
t := styles.CurrentTheme()
w, h := m.imagePreviewSize()
if m.currentImage() == "" {
@@ -125,16 +142,16 @@ func (m *filePicker) imagePreview() string {
return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
}
-func (m *filePicker) imagePreviewStyle() lipgloss.Style {
+func (m *model) imagePreviewStyle() lipgloss.Style {
t := styles.CurrentTheme()
return t.S().Base.Padding(1, 1, 1, 1)
}
-func (m *filePicker) imagePreviewSize() (int, int) {
+func (m *model) imagePreviewSize() (int, int) {
return m.width - 4, min(20, m.wHeight/2)
}
-func (m *filePicker) style() lipgloss.Style {
+func (m *model) style() lipgloss.Style {
t := styles.CurrentTheme()
return t.S().Base.
Width(m.width).
@@ -143,12 +160,12 @@ func (m *filePicker) style() lipgloss.Style {
}
// ID implements FilePicker.
-func (m *filePicker) ID() dialogs.DialogID {
+func (m *model) ID() dialogs.DialogID {
return FilePickerID
}
// Position implements FilePicker.
-func (m *filePicker) Position() (int, int) {
+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
diff --git a/internal/tui/components/image/image.go b/internal/tui/components/image/image.go
index d1dbfea85c825a66c4543e152511a767c874f838..5d84c18e984c0e252064f2973263f9390118e244 100644
--- a/internal/tui/components/image/image.go
+++ b/internal/tui/components/image/image.go
@@ -3,7 +3,6 @@
package image
import (
- "context"
"fmt"
_ "image/jpeg"
_ "image/png"
@@ -17,8 +16,6 @@ type Model struct {
width uint
height uint
err error
-
- cancelAnimation context.CancelFunc
}
func New(width, height uint, url string) Model {
@@ -38,11 +35,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case errMsg:
m.err = msg
return m, nil
- case rewdrawMsg:
+ case redrawMsg:
m.width = msg.width
m.height = msg.height
m.url = msg.url
- return m, loadUrl(m.url)
+ return m, loadURL(m.url)
case loadMsg:
return handleLoadMsg(m, msg)
}
@@ -60,7 +57,7 @@ type errMsg struct{ error }
func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
return func() tea.Msg {
- return rewdrawMsg{
+ return redrawMsg{
width: width,
height: height,
url: url,
@@ -68,9 +65,9 @@ func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
}
}
-func (m Model) UpdateUrl(url string) tea.Cmd {
+func (m Model) UpdateURL(url string) tea.Cmd {
return func() tea.Msg {
- return rewdrawMsg{
+ return redrawMsg{
width: m.width,
height: m.height,
url: url,
@@ -78,7 +75,7 @@ func (m Model) UpdateUrl(url string) tea.Cmd {
}
}
-type rewdrawMsg struct {
+type redrawMsg struct {
width uint
height uint
url string
diff --git a/internal/tui/components/image/load.go b/internal/tui/components/image/load.go
index f6015b8e2725bf3a5380eef11357e1b779bba62f..67308ef41be5c95aa7985366ad247674161dc7bc 100644
--- a/internal/tui/components/image/load.go
+++ b/internal/tui/components/image/load.go
@@ -6,7 +6,6 @@ import (
"image"
"image/png"
"io"
- "io/ioutil"
"net/http"
"os"
"strings"
@@ -24,7 +23,7 @@ type loadMsg struct {
io.ReadCloser
}
-func loadUrl(url string) tea.Cmd {
+func loadURL(url string) tea.Cmd {
var r io.ReadCloser
var err error
@@ -52,20 +51,9 @@ func load(r io.ReadCloser) tea.Cmd {
}
func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
- if m.cancelAnimation != nil {
- m.cancelAnimation()
- }
-
- // blank out image so it says "loading..."
- m.image = ""
-
- return handleLoadMsgStatic(m, msg)
-}
-
-func handleLoadMsgStatic(m Model, msg loadMsg) (Model, tea.Cmd) {
defer msg.Close()
- img, err := readerToimage(m.width, m.height, m.url, msg)
+ img, err := readerToImage(m.width, m.height, m.url, msg)
if err != nil {
return m, func() tea.Msg { return errMsg{err} }
}
@@ -73,7 +61,7 @@ func handleLoadMsgStatic(m Model, msg loadMsg) (Model, tea.Cmd) {
return m, nil
}
-func imageToString(width, height uint, url string, img image.Image) (string, error) {
+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
@@ -84,7 +72,7 @@ func imageToString(width, height uint, url string, img image.Image) (string, err
for x := w; x < int(width); x = x + 2 {
str.WriteString(" ")
}
- for x := 0; x < w; x++ {
+ for x := range w {
c1, _ := colorful.MakeColor(img.At(x, y))
color1 := p.Color(c1.Hex())
c2, _ := colorful.MakeColor(img.At(x, y+1))
@@ -99,9 +87,9 @@ func imageToString(width, height uint, url string, img image.Image) (string, err
return str.String(), nil
}
-func readerToimage(width uint, height uint, url string, r io.Reader) (string, error) {
+func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) {
if strings.HasSuffix(strings.ToLower(url), ".svg") {
- return svgToimage(width, height, url, r)
+ return svgToImage(width, height, r)
}
img, _, err := imageorient.Decode(r)
@@ -109,15 +97,15 @@ func readerToimage(width uint, height uint, url string, r io.Reader) (string, er
return "", err
}
- return imageToString(width, height, url, img)
+ return imageToString(width, height, img)
}
-func svgToimage(width uint, height uint, url string, r io.Reader) (string, error) {
+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 := ioutil.TempFile("", "imgcat.*.png")
+ tmpPngFile, err := os.CreateTemp("", "img.*.png")
if err != nil {
return "", err
}
@@ -153,5 +141,5 @@ func svgToimage(width uint, height uint, url string, r io.Reader) (string, error
if err != nil {
return "", err
}
- return imageToString(width, height, url, img)
+ return imageToString(width, height, img)
}
From 13c3585e1377759d56e7c3acfff63a2af2cbec8d Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 17:47:51 +0200
Subject: [PATCH 59/73] fix(chat): fix focus selection
---
internal/tui/components/chat/chat.go | 33 ++++++-----------
internal/tui/components/core/list/list.go | 45 +++++++++++++++--------
2 files changed, 41 insertions(+), 37 deletions(-)
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index eaff4c7e3b697abd18aa9b2831ed2814e4c7c2cf..3be9a9fd913f33cdce167e283c88275ffed14ad9 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -46,8 +46,7 @@ type messageListCmp struct {
width, height int
session session.Session
listCmp list.ListModel
- focused bool // Focus state for styling
- previousSelected int // Last selected item index for restoring focus
+ previousSelected int // Last selected item index for restoring focus
lastUserMessageTime int64
}
@@ -56,20 +55,21 @@ type messageListCmp struct {
// 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: list.New(
- list.WithGapSize(1),
- list.WithReverse(true),
- list.WithKeyMap(defaultKeymaps),
- ),
+ app: app,
+ listCmp: listCmp,
previousSelected: list.NoSelection,
}
}
// Init initializes the component (no initialization needed).
func (m *messageListCmp) Init() tea.Cmd {
- return nil
+ return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
}
// Update handles incoming messages and updates the component state.
@@ -483,24 +483,15 @@ func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
// Blur implements MessageListCmp.
func (m *messageListCmp) Blur() tea.Cmd {
- m.focused = false
- m.previousSelected = m.listCmp.SelectedIndex()
- m.listCmp.ClearSelection()
- return nil
+ return m.listCmp.Blur()
}
// Focus implements MessageListCmp.
func (m *messageListCmp) Focus() tea.Cmd {
- m.focused = true
- if m.previousSelected != list.NoSelection {
- m.listCmp.SetSelected(m.previousSelected)
- } else {
- m.listCmp.SetSelected(len(m.listCmp.Items()) - 1)
- }
- return nil
+ return m.listCmp.Focus()
}
// IsFocused implements MessageListCmp.
func (m *messageListCmp) IsFocused() bool {
- return m.focused
+ return m.listCmp.IsFocused()
}
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index cfbcc89033d49dd7cdb8af32351dd1cedcbe27ec..8f22ccc7c8f73b16ff47f85882e1ee4bc3e2c8bf 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -31,6 +31,7 @@ const (
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
@@ -40,7 +41,6 @@ type ListModel interface {
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
- ClearSelection() tea.Cmd // Clear the current selection
Filter(string) tea.Cmd // Filter items based on a search term
}
@@ -143,6 +143,8 @@ type model struct {
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.
@@ -238,6 +240,7 @@ func New(opts ...listOptions) ListModel {
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)
@@ -746,8 +749,10 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
func (m *model) goToBottom() tea.Cmd {
cmds := []tea.Cmd{m.blurSelected()}
m.viewState.reverse = true
- m.selectionState.selectedIndex = m.findLastSelectableItem()
- cmds = append(cmds, m.focusSelected())
+ if m.isFocused {
+ m.selectionState.selectedIndex = m.findLastSelectableItem()
+ cmds = append(cmds, m.focusSelected())
+ }
m.ResetView()
return tea.Batch(cmds...)
}
@@ -774,6 +779,9 @@ func (m *model) ResetView() {
// 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
}
@@ -953,9 +961,7 @@ func (m *model) UpdateItem(inx int, item util.Model) {
}
m.filteredItems[inx] = item
if m.selectionState.selectedIndex == inx {
- if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
- i.Focus()
- }
+ m.focusSelected()
}
m.setItemSize(inx)
m.rerenderItem(inx)
@@ -1334,14 +1340,21 @@ func (m *model) SetSelected(index int) tea.Cmd {
return tea.Batch(cmds...)
}
-// ClearSelection clears the current selection and focus.
-func (m *model) ClearSelection() tea.Cmd {
- cmds := []tea.Cmd{}
- if m.selectionState.selectedIndex >= 0 && m.selectionState.selectedIndex < len(m.filteredItems) {
- if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
- cmds = append(cmds, i.Blur())
- }
- }
- m.selectionState.selectedIndex = NoSelection
- 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
}
From e74dddbeeda031633e936e535fdabb828915d6b1 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 18:04:05 +0200
Subject: [PATCH 60/73] feat: add file picker help
---
.../dialogs/filepicker/filepicker.go | 18 ++++++++++++++++--
.../tui/components/dialogs/filepicker/keys.go | 8 ++------
2 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go
index c3bda21e1577e47ec7b679eb958c70c5160e13a0..1a427a2a19751c3a5888d0b44eea00cb84042d3f 100644
--- a/internal/tui/components/dialogs/filepicker/filepicker.go
+++ b/internal/tui/components/dialogs/filepicker/filepicker.go
@@ -5,6 +5,7 @@ import (
"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/lipgloss/v2"
@@ -36,6 +37,8 @@ type model struct {
filePicker filepicker.Model
highlightedFile string
image image.Model
+ keyMap KeyMap
+ help help.Model
}
func NewFilePickerCmp() FilePicker {
@@ -51,7 +54,15 @@ func NewFilePickerCmp() FilePicker {
fp.SetHeight(fileSelectionHight)
image := image.New(1, 1, "")
- return &model{filePicker: fp, image: image}
+
+ help := help.New()
+ help.Styles = t.S().Help
+ return &model{
+ filePicker: fp,
+ image: image,
+ keyMap: DefaultKeyMap(),
+ help: help,
+ }
}
func (m *model) Init() tea.Cmd {
@@ -71,8 +82,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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()
@@ -114,6 +127,7 @@ func (m *model) View() tea.View {
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))
}
diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go
index d5f3a971808ff30e1503e1c4654944b867fd90b0..f8b18a93534853073be473e1aba6ce5332f0d488 100644
--- a/internal/tui/components/dialogs/filepicker/keys.go
+++ b/internal/tui/components/dialogs/filepicker/keys.go
@@ -12,7 +12,6 @@ type KeyMap struct {
Up,
Forward,
Backward,
- InsertCWD,
Close key.Binding
}
@@ -38,10 +37,7 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("left", "h"),
key.WithHelp("left/h", "move backward"),
),
- InsertCWD: key.NewBinding(
- key.WithKeys("i"),
- key.WithHelp("i", "manual path input"),
- ),
+
Close: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close/exit"),
@@ -63,8 +59,8 @@ func (k KeyMap) FullHelp() [][]key.Binding {
// ShortHelp implements help.KeyMap.
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{
- k.InsertCWD,
key.NewBinding(
+ key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
key.WithHelp("↑↓←→", "navigate"),
),
k.Select,
From 75178a44cc78d9ff7751172d1a1472ab6cacbfd6 Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Wed, 4 Jun 2025 14:27:16 -0300
Subject: [PATCH 61/73] feat: profiling
---
Taskfile.yaml | 22 ++++++++++++++++++++++
main.go | 14 ++++++++++++++
2 files changed, 36 insertions(+)
diff --git a/Taskfile.yaml b/Taskfile.yaml
index 285b6bf3ebda1044ffdd2dbe8925e03b73b39d19..827db449c673fd545cbb042b3bb407273d8b2872 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -17,3 +17,25 @@ tasks:
desc: Run gofumpt
cmds:
- gofumpt -w .
+
+ dev:
+ desc: Run with profiling enabled
+ env:
+ OPENCODE_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/main.go b/main.go
index 857344ef52f4b4928e2ea0f794b8d9a1753c0616..0e95a17f366793f747e161942594710a4f4cdc48 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,11 @@
package main
import (
+ "net/http"
+ "os"
+
+ _ "net/http/pprof" // profiling
+
"github.com/opencode-ai/opencode/cmd"
"github.com/opencode-ai/opencode/internal/logging"
)
@@ -10,5 +15,14 @@ func main() {
logging.ErrorPersist("Application terminated due to unhandled panic")
})
+ if os.Getenv("OPENCODE_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()
}
From 25c600701564ad54986415ddf7c6a4338fd7bb4d Mon Sep 17 00:00:00 2001
From: Carlos Alexandro Becker
Date: Wed, 4 Jun 2025 22:28:29 -0300
Subject: [PATCH 62/73] fix: crush
---
Taskfile.yaml | 2 +-
main.go | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Taskfile.yaml b/Taskfile.yaml
index 827db449c673fd545cbb042b3bb407273d8b2872..e2f59da4161a904051601ef5fc5e6176fe75910b 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -21,7 +21,7 @@ tasks:
dev:
desc: Run with profiling enabled
env:
- OPENCODE_PROFILE: true
+ CRUSH_PROFILE: true
cmds:
- go run .
diff --git a/main.go b/main.go
index 0e95a17f366793f747e161942594710a4f4cdc48..031ce3c9ed037cd5b34aa521f62bc9ca03b6ac5d 100644
--- a/main.go
+++ b/main.go
@@ -15,7 +15,7 @@ func main() {
logging.ErrorPersist("Application terminated due to unhandled panic")
})
- if os.Getenv("OPENCODE_PROFILE") != "" {
+ if os.Getenv("CRUSH_PROFILE") != "" {
go func() {
logging.Info("Serving pprof at localhost:6060")
if httpErr := http.ListenAndServe("localhost:6060", nil); httpErr != nil {
From 06e8c755ef18a2105c1a16fe02f61ddde366882a Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 20:57:32 +0200
Subject: [PATCH 63/73] chore: change the module
---
README.md | 8 ++---
Taskfile.yaml | 5 +++
cmd/root.go | 18 +++++-----
cmd/schema/main.go | 4 +--
go.mod | 3 +-
go.sum | 3 --
install | 6 ++--
internal/app/app.go | 20 +++++------
internal/app/lsp.go | 8 ++---
internal/config/config.go | 4 +--
internal/db/connect.go | 4 +--
internal/diff/diff.go | 6 ++--
internal/fileutil/fileutil.go | 2 +-
internal/highlight/highlight.go | 2 +-
internal/history/file.go | 4 +--
internal/llm/agent/agent-tool.go | 10 +++---
internal/llm/agent/agent.go | 20 +++++------
internal/llm/agent/mcp-tools.go | 10 +++---
internal/llm/agent/tools.go | 12 +++----
internal/llm/models/local.go | 2 +-
internal/llm/prompt/coder.go | 6 ++--
internal/llm/prompt/prompt.go | 6 ++--
internal/llm/prompt/prompt_test.go | 2 +-
internal/llm/prompt/summarizer.go | 2 +-
internal/llm/prompt/task.go | 2 +-
internal/llm/prompt/title.go | 2 +-
internal/llm/provider/anthropic.go | 10 +++---
internal/llm/provider/bedrock.go | 4 +--
internal/llm/provider/gemini.go | 8 ++---
internal/llm/provider/openai.go | 10 +++---
internal/llm/provider/provider.go | 6 ++--
internal/llm/provider/vertexai.go | 2 +-
internal/llm/tools/bash.go | 6 ++--
internal/llm/tools/diagnostics.go | 4 +--
internal/llm/tools/edit.go | 12 +++----
internal/llm/tools/fetch.go | 4 +--
internal/llm/tools/glob.go | 6 ++--
internal/llm/tools/grep.go | 4 +--
internal/llm/tools/ls.go | 4 +--
internal/llm/tools/patch.go | 12 +++----
internal/llm/tools/shell/shell.go | 2 +-
internal/llm/tools/view.go | 4 +--
internal/llm/tools/write.go | 12 +++----
internal/logging/writer.go | 2 +-
internal/lsp/client.go | 6 ++--
internal/lsp/handlers.go | 8 ++---
internal/lsp/language.go | 2 +-
internal/lsp/methods.go | 2 +-
internal/lsp/transport.go | 4 +--
internal/lsp/util/edit.go | 2 +-
internal/lsp/watcher/watcher.go | 8 ++---
internal/message/content.go | 2 +-
internal/message/message.go | 6 ++--
internal/permission/permission.go | 4 +--
internal/session/session.go | 4 +--
internal/tui/components/anim/anim.go | 4 +--
internal/tui/components/chat/chat.go | 18 +++++-----
internal/tui/components/chat/editor/editor.go | 20 +++++------
internal/tui/components/chat/editor/keys.go | 2 +-
.../tui/components/chat/messages/messages.go | 16 ++++-----
.../tui/components/chat/messages/renderer.go | 12 +++----
internal/tui/components/chat/messages/tool.go | 10 +++---
.../tui/components/chat/sidebar/sidebar.go | 20 +++++------
.../tui/components/completions/completions.go | 6 ++--
internal/tui/components/completions/item.go | 8 ++---
internal/tui/components/completions/keys.go | 2 +-
internal/tui/components/core/helpers.go | 2 +-
internal/tui/components/core/list/keys.go | 2 +-
internal/tui/components/core/list/list.go | 8 ++---
internal/tui/components/core/status/keys.go | 2 +-
internal/tui/components/core/status/status.go | 10 +++---
internal/tui/components/dialog/init.go | 4 +--
internal/tui/components/dialog/permission.go | 12 +++----
.../components/dialogs/commands/arguments.go | 6 ++--
.../components/dialogs/commands/commands.go | 14 ++++----
.../tui/components/dialogs/commands/item.go | 10 +++---
.../tui/components/dialogs/commands/keys.go | 2 +-
.../tui/components/dialogs/commands/loader.go | 4 +--
internal/tui/components/dialogs/dialogs.go | 2 +-
.../dialogs/filepicker/filepicker.go | 10 +++---
.../tui/components/dialogs/filepicker/keys.go | 2 +-
internal/tui/components/dialogs/keys.go | 2 +-
.../tui/components/dialogs/models/keys.go | 2 +-
.../tui/components/dialogs/models/models.go | 18 +++++-----
internal/tui/components/dialogs/quit/keys.go | 2 +-
internal/tui/components/dialogs/quit/quit.go | 8 ++---
.../tui/components/dialogs/sessions/keys.go | 2 +-
.../components/dialogs/sessions/sessions.go | 16 ++++-----
internal/tui/components/image/load.go | 5 +--
internal/tui/components/logo/logo.go | 2 +-
internal/tui/components/logs/details.go | 8 ++---
internal/tui/components/logs/table.go | 10 +++---
internal/tui/keys.go | 2 +-
internal/tui/layout/container.go | 4 +--
internal/tui/layout/split.go | 4 +--
internal/tui/page/chat/chat.go | 22 ++++++------
internal/tui/page/chat/keys.go | 2 +-
internal/tui/page/logs.go | 8 ++---
internal/tui/tui.go | 34 +++++++++----------
internal/version/version.go | 2 +-
main.go | 4 +--
101 files changed, 349 insertions(+), 347 deletions(-)
diff --git a/README.md b/README.md
index b98c18301e09c5938bd67a0bb79dd8dbe3abfa1f..39fec806a2d299dffefb039404aeae11ea37e55e 100644
--- a/README.md
+++ b/README.md
@@ -34,10 +34,10 @@ 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)
@@ -59,7 +59,7 @@ paru -S opencode-ai-bin
### Using Go
```bash
-go install github.com/opencode-ai/opencode@latest
+go install github.com/charmbracelet/crush@latest
```
## Configuration
@@ -613,7 +613,7 @@ 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
+git clone https://github.com/charmbracelet/crush.git
cd opencode
# Build
diff --git a/Taskfile.yaml b/Taskfile.yaml
index e2f59da4161a904051601ef5fc5e6176fe75910b..7e181d84513bc1abc5d5a32807f88a75898633ea 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -8,6 +8,11 @@ tasks:
cmds:
- golangci-lint run
+ lint-fix:
+ desc: Run base linters and fix issues
+ cmds:
+ - golangci-lint run --fix
+
test:
desc: Run tests
cmds:
diff --git a/cmd/root.go b/cmd/root.go
index 160be9dbec092493a6607326f9b9ea5304004d50..9a8748f5252773176b490b172fd8c14c26e7bc12 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -8,15 +8,15 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea/v2"
- "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"
+ "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"
)
diff --git a/cmd/schema/main.go b/cmd/schema/main.go
index 429267bc9519f62b1376ecb8cf4089d93a0b09f1..b638fb7a0d2b113304c4338779332bd6ad2d7bf9 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
diff --git a/go.mod b/go.mod
index edb43f7075b9959941ac398c85fafb9d6d6e4610..8123d187820eb1b4873428b7e61249b55c960ff1 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module github.com/opencode-ai/opencode
+module github.com/charmbracelet/crush
go 1.24.0
@@ -76,7 +76,6 @@ require (
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/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
diff --git a/go.sum b/go.sum
index 0811dc5e595582b364af04758a219f2cbe634441..7af16ab0ae6cbea3532270af03847e6e747cfdff 100644
--- a/go.sum
+++ b/go.sum
@@ -102,8 +102,6 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G
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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
-github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
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=
@@ -282,7 +280,6 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
-golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
diff --git a/install b/install
index b58aa14e23364f702ada60a8db2c0403b9956fec..8d394d34b930e9d9afe8c359c5a0ae52cfa00e76 100755
--- a/install
+++ b/install
@@ -40,15 +40,15 @@ INSTALL_DIR=$HOME/.opencode/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
diff --git a/internal/app/app.go b/internal/app/app.go
index 9ebb00ea9bfe6823ec6f02bfd1f6111f6ec58adf..29c77308111e09f8174ea7f7ceddd30948db8cf1 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -9,16 +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/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 {
diff --git a/internal/app/lsp.go b/internal/app/lsp.go
index c04cc42398423d88e9277ce57cddc54ebcc8a66a..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) {
diff --git a/internal/config/config.go b/internal/config/config.go
index 5a0905bba239c0d7c79f669801ef9b3a5caa9cf9..5ed55552d9d4f07c4d4e00f8d7980880d05e8a34 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"
)
diff --git a/internal/db/connect.go b/internal/db/connect.go
index b8fcb736261adc9b5e6c06cd02a8364eec87acea..3881dd34bdc16a9a893d24377eafcd1f59e7aace 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"
)
diff --git a/internal/diff/diff.go b/internal/diff/diff.go
index 58545566e9035ed4122e103ed624972fa27ce4f2..89087ecc7594c6feab222c5b7ea288db1ac112e4 100644
--- a/internal/diff/diff.go
+++ b/internal/diff/diff.go
@@ -8,11 +8,11 @@ import (
"strings"
"github.com/aymanbagabas/go-udiff"
+ "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/highlight"
- "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/sergi/go-diff/diffmatchpatch"
)
diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go
index 125979df7b98247dcde89980671fddf851dfb2ef..94013b7f3e27abb9e4240d62e72176d1f8576067 100644
--- a/internal/fileutil/fileutil.go
+++ b/internal/fileutil/fileutil.go
@@ -11,7 +11,7 @@ import (
"github.com/bmatcuk/doublestar/v4"
"github.com/charlievieth/fastwalk"
- "github.com/opencode-ai/opencode/internal/logging"
+ "github.com/charmbracelet/crush/internal/logging"
ignore "github.com/sabhiram/go-gitignore"
)
diff --git a/internal/highlight/highlight.go b/internal/highlight/highlight.go
index 6517357a1b2a789d0f49ab13dbc5a0cc9e92bfed..3b9c7643cf0d6b281c5eb66523cb97ee4197faf6 100644
--- a/internal/highlight/highlight.go
+++ b/internal/highlight/highlight.go
@@ -9,7 +9,7 @@ import (
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
chromaStyles "github.com/alecthomas/chroma/v2/styles"
- "github.com/opencode-ai/opencode/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/tui/styles"
)
func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
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 511cf62996bd6e0d506a428344f34d89e515c82a..9120c76aff8d5efa7161b4fab73577d31991e07a 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
diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go
index 2375606416e144db5ada7b0ab4309c7987aa8080..32ce8287a257438f01fbb6f52770677cb18b3b30 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"
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 6ff8391b48acf2d8553631b7a15ce9b758d0b480..3a50fdf48fe86167600eceee3cce26b6caac900e 100644
--- a/internal/llm/models/local.go
+++ b/internal/llm/models/local.go
@@ -11,7 +11,7 @@ import (
"strings"
"unicode"
- "github.com/opencode-ai/opencode/internal/logging"
+ "github.com/charmbracelet/crush/internal/logging"
"github.com/spf13/viper"
)
diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go
index 495f2406a435fec54cfea9ac4abffd4e839c28e8..085dc9bec55b2b1def04082fed66b5859c676fce 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 {
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 bcd9e20993a4e0b4555d9dc82e46330938223b72..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"
)
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..1ec3c9bc82b6568158483cc2913a9b9e8c5fdc56 100644
--- a/internal/llm/prompt/task.go
+++ b/internal/llm/prompt/task.go
@@ -3,7 +3,7 @@ package prompt
import (
"fmt"
- "github.com/opencode-ai/opencode/internal/llm/models"
+ "github.com/charmbracelet/crush/internal/llm/models"
)
func TaskPrompt(_ models.ModelProvider) string {
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 4b558e2fb18fe411e1dfbbc3652a2246375a9929..77edc8e0519e6f82b0c807626dfebbcd5c09d3a4 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 {
diff --git a/internal/llm/provider/bedrock.go b/internal/llm/provider/bedrock.go
index 9fa3ca87f984147a3137fa013484b453d37d9687..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 {
diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go
index 96cf02a8b311bb5fa536394452fe9cb05713faaa..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"
)
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..558eec31059ecb068897fe09397813bbaafe6afd 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
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..33e703cca4d559ea32d97786c191dcae49855c43 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 {
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..105733dc680ef40efb57a2ecb735af1e6b1463ea 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 {
diff --git a/internal/llm/tools/glob.go b/internal/llm/tools/glob.go
index 5726c612ef8de79fbf05e227bdedb346b48e7add..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 (
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 383fc50507585382ec2611a03ac0d2c58f4e09b4..81539abe8db1ce2ccde5b8261d34474ab77ea076 100644
--- a/internal/llm/tools/ls.go
+++ b/internal/llm/tools/ls.go
@@ -8,8 +8,8 @@ import (
"path/filepath"
"strings"
- "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 LSParams struct {
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 cc127cd0cab5cde909e8c1fe9760c4bbefd57f8f..0f0f88afced34a53fef34cfda83aec637ca02f5b 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 {
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/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 1b68dc68df719d2128bfb1fe04028115a14e51b0..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
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 6e0fd40b4946a709cf10dc55d1d422447c03a23f..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 {
diff --git a/internal/permission/permission.go b/internal/permission/permission.go
index 3532f5be685608f2dbb0e992924b4606f2db96d8..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")
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
index c39de0d899a1b4eaf3896ea32b02883374af1195..0bd7a7753f114c7bfb6c8f9898772c49ae8f7d80 100644
--- a/internal/tui/components/anim/anim.go
+++ b/internal/tui/components/anim/anim.go
@@ -9,11 +9,11 @@ import (
"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"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
const (
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
index 3be9a9fd913f33cdce167e283c88275ffed14ad9..778d36b10d11a804be7ca0c65b23e632d745f3ab 100644
--- a/internal/tui/components/chat/chat.go
+++ b/internal/tui/components/chat/chat.go
@@ -5,16 +5,16 @@ import (
"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"
- "github.com/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/llm/agent"
- "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/chat/messages"
- "github.com/opencode-ai/opencode/internal/tui/components/core/list"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
type SendMsg struct {
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index aae5d3ec5b0c51c99f328ebb489af06c537adbe8..d8ae8d71d6dfe4038c73fe6e0bd1b686c0c071e5 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -11,17 +11,17 @@ import (
"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/layout"
+ "github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/fileutil"
- "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/chat"
- "github.com/opencode-ai/opencode/internal/tui/components/completions"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
type FileCompletionItem struct {
diff --git a/internal/tui/components/chat/editor/keys.go b/internal/tui/components/chat/editor/keys.go
index 69bffd81c1ad1214be49d73bab2e36d019a87ba4..a53e18245cab3e330a54ebbf00d4b12ec3f4e7b7 100644
--- a/internal/tui/components/chat/editor/keys.go
+++ b/internal/tui/components/chat/editor/keys.go
@@ -2,7 +2,7 @@ package editor
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
type EditorKeyMap struct {
diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go
index f04fe6a08a97175d2c69a01f75096394a2d3aef5..f35e1af9dd542f6225d7adad8e26256d55a9e919 100644
--- a/internal/tui/components/chat/messages/messages.go
+++ b/internal/tui/components/chat/messages/messages.go
@@ -8,15 +8,15 @@ import (
"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/opencode-ai/opencode/internal/llm/models"
-
- "github.com/opencode-ai/opencode/internal/message"
- "github.com/opencode-ai/opencode/internal/tui/components/anim"
- "github.com/opencode-ai/opencode/internal/tui/components/core"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
+
+ "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.
diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index c67d2bbed7c8793969400459e81325b5c92cdf56..eda0565d26c1113d1b856b51cc254fde4dac1bc6 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -7,15 +7,15 @@ import (
"strings"
"time"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/diff"
+ "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"
- "github.com/opencode-ai/opencode/internal/config"
- "github.com/opencode-ai/opencode/internal/diff"
- "github.com/opencode-ai/opencode/internal/highlight"
- "github.com/opencode-ai/opencode/internal/llm/agent"
- "github.com/opencode-ai/opencode/internal/llm/tools"
- "github.com/opencode-ai/opencode/internal/tui/styles"
)
// responseContextHeight limits the number of lines displayed in tool output
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index fa9de764ee20a90b4680e8495eb57bc64e028f4c..94bff77c4c9fb85a72a5f8230bf38edba303c8c7 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -5,13 +5,13 @@ import (
"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"
- "github.com/opencode-ai/opencode/internal/message"
- "github.com/opencode-ai/opencode/internal/tui/components/anim"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
// ToolCallCmp defines the interface for tool call components in the chat interface.
diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go
index d75b70f596b9c7564846bc5962d31f1d519cbdf4..425b1468a50a5590ca23f100b61f79e8e3802867 100644
--- a/internal/tui/components/chat/sidebar/sidebar.go
+++ b/internal/tui/components/chat/sidebar/sidebar.go
@@ -5,17 +5,17 @@ import (
"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"
- "github.com/opencode-ai/opencode/internal/config"
- "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/logo"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
- "github.com/opencode-ai/opencode/internal/version"
)
const (
diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go
index 392af550050407cf321578fa6906740ee13c1169..625c49caba3ca070d07902845e82478d8064274e 100644
--- a/internal/tui/components/completions/completions.go
+++ b/internal/tui/components/completions/completions.go
@@ -3,10 +3,10 @@ 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"
- "github.com/opencode-ai/opencode/internal/tui/components/core/list"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
type Completion struct {
diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go
index 324c07249bc784366e33f717d5a59d20b2eff7bf..ceab34a5ccbb58ce318b57764a51e4fcc407d2ce 100644
--- a/internal/tui/components/completions/item.go
+++ b/internal/tui/components/completions/item.go
@@ -4,12 +4,12 @@ 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/opencode-ai/opencode/internal/tui/components/core/list"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
"github.com/rivo/uniseg"
)
diff --git a/internal/tui/components/completions/keys.go b/internal/tui/components/completions/keys.go
index c135df01bfe4774d9bef57da4b6cfc28e4034405..41bdeb384f79ea6d81ce45d12c5555ac32c04038 100644
--- a/internal/tui/components/completions/keys.go
+++ b/internal/tui/components/completions/keys.go
@@ -2,7 +2,7 @@ package completions
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
type KeyMap struct {
diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go
index 69b538976f9a2428f7eb369fc16c6aec3d9fd94d..eda256e2a82b54099d92ee9caea6c9e6c05d6088 100644
--- a/internal/tui/components/core/helpers.go
+++ b/internal/tui/components/core/helpers.go
@@ -4,9 +4,9 @@ import (
"image/color"
"strings"
+ "github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
- "github.com/opencode-ai/opencode/internal/tui/styles"
)
func Section(text string, width int) string {
diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go
index 46b6cf2b01d67e097799de0df11c34b3efa436f6..c5368354c06357a6a9b209f1896f336c97f4ea13 100644
--- a/internal/tui/components/core/list/keys.go
+++ b/internal/tui/components/core/list/keys.go
@@ -2,7 +2,7 @@ package list
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
type KeyMap struct {
diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go
index 8f22ccc7c8f73b16ff47f85882e1ee4bc3e2c8bf..6cb2756aee506fdc5b421597675d47402a7f61c2 100644
--- a/internal/tui/components/core/list/list.go
+++ b/internal/tui/components/core/list/list.go
@@ -10,11 +10,11 @@ import (
"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/opencode-ai/opencode/internal/tui/components/anim"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
"github.com/sahilm/fuzzy"
)
diff --git a/internal/tui/components/core/status/keys.go b/internal/tui/components/core/status/keys.go
index 1c7a794ba96c1618cdef986c48ff36c492d1bacf..f2572ed1745a927aa0158c45ae1ba3228a67446f 100644
--- a/internal/tui/components/core/status/keys.go
+++ b/internal/tui/components/core/status/keys.go
@@ -2,7 +2,7 @@ package status
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
type KeyMap struct {
diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go
index a85ef26e21be723f0ae3dcf7a69f50a9cff11fa7..796d2edf634a08d1b3fbf42d67c0ff818de59b75 100644
--- a/internal/tui/components/core/status/status.go
+++ b/internal/tui/components/core/status/status.go
@@ -5,11 +5,11 @@ import (
"github.com/charmbracelet/bubbles/v2/help"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/opencode-ai/opencode/internal/logging"
- "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/util"
+ "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 {
diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go
index 261516b787cca0a9b0142146652b7a41ec7d41c0..0abf3af80fadae6d4fad1b9154a969810286cc2a 100644
--- a/internal/tui/components/dialog/init.go
+++ b/internal/tui/components/dialog/init.go
@@ -5,8 +5,8 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
+ "github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/tui/util"
)
// InitDialogCmp is a component that asks the user if they want to initialize the project.
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index 241dcca1ad3bbd52c97d2ba8306cb32a398a003a..7ecc923e494d4680085ba33459d65dc0516c0539 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -7,13 +7,13 @@ import (
"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/llm/tools"
+ "github.com/charmbracelet/crush/internal/permission"
+ "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/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/util"
)
type PermissionAction string
diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go
index f08436d299a1f825dd7f525dd5290e7af9a8ed14..1128acf21b031ab914662f6686ffc9f57b9b7653 100644
--- a/internal/tui/components/dialogs/commands/arguments.go
+++ b/internal/tui/components/dialogs/commands/arguments.go
@@ -8,10 +8,10 @@ import (
"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"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
const (
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index 90ca45fa8a801bd8122fb0ebee9e855e46c08092..718b49599a3267151e459653fd28861db0acf24f 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -6,13 +6,13 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/tui/components/chat"
- "github.com/opencode-ai/opencode/internal/tui/components/completions"
- "github.com/opencode-ai/opencode/internal/tui/components/core"
- "github.com/opencode-ai/opencode/internal/tui/components/core/list"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
+ "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 (
diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go
index bc8c11edc2ead5ae52a6c83a678c4df8807e1be5..b0db2c9c35424eb7f3ef9ddc2b20d85efcd7e6a4 100644
--- a/internal/tui/components/dialogs/commands/item.go
+++ b/internal/tui/components/dialogs/commands/item.go
@@ -2,12 +2,12 @@ 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"
- "github.com/opencode-ai/opencode/internal/tui/components/core"
- "github.com/opencode-ai/opencode/internal/tui/components/core/list"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
type ItemSection interface {
diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go
index 9b80591678b97af6c70aa2794e9e980d229fe441..96df76a20ef4764f201abd504fea2ee15270c76d 100644
--- a/internal/tui/components/dialogs/commands/keys.go
+++ b/internal/tui/components/dialogs/commands/keys.go
@@ -2,7 +2,7 @@ package commands
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
type CommandsDialogKeyMap struct {
diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go
index 92064394fa7b9f832dce7d9fd82b20a24e1127c2..447d7c6412c191934563f7351630d6832424846b 100644
--- a/internal/tui/components/dialogs/commands/loader.go
+++ b/internal/tui/components/dialogs/commands/loader.go
@@ -9,8 +9,8 @@ import (
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/opencode-ai/opencode/internal/config"
- "github.com/opencode-ai/opencode/internal/tui/util"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/tui/util"
)
const (
diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go
index 58a25ae446309ca3f33bfb1aafc407453fff61f6..9153500a724915e858d18ab449c3b16ced39a548 100644
--- a/internal/tui/components/dialogs/dialogs.go
+++ b/internal/tui/components/dialogs/dialogs.go
@@ -4,8 +4,8 @@ import (
"slices"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
type DialogID string
diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go
index 1a427a2a19751c3a5888d0b44eea00cb84042d3f..6b67e309e66c4455835c2315062c6c4f9081a169 100644
--- a/internal/tui/components/dialogs/filepicker/filepicker.go
+++ b/internal/tui/components/dialogs/filepicker/filepicker.go
@@ -8,12 +8,12 @@ import (
"github.com/charmbracelet/bubbles/v2/help"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
+ "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"
- "github.com/opencode-ai/opencode/internal/tui/components/core"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
- "github.com/opencode-ai/opencode/internal/tui/components/image"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
const (
diff --git a/internal/tui/components/dialogs/filepicker/keys.go b/internal/tui/components/dialogs/filepicker/keys.go
index f8b18a93534853073be473e1aba6ce5332f0d488..0143eaaddd0b938c458c5f5995497cb94d782735 100644
--- a/internal/tui/components/dialogs/filepicker/keys.go
+++ b/internal/tui/components/dialogs/filepicker/keys.go
@@ -2,7 +2,7 @@ package filepicker
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
// KeyMap defines keyboard bindings for dialog management.
diff --git a/internal/tui/components/dialogs/keys.go b/internal/tui/components/dialogs/keys.go
index a3b68acb6e4d6b1773aa84933668f94bbc6a4e16..83334cf4c9c315151f915d75be9470de21cff961 100644
--- a/internal/tui/components/dialogs/keys.go
+++ b/internal/tui/components/dialogs/keys.go
@@ -2,7 +2,7 @@ package dialogs
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
// KeyMap defines keyboard bindings for dialog management.
diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go
index 17d21193edaf6b6bfa1ec4f53a9e91b8fba28b80..94c08e37afa6002f3b4258c5ef8377cf62b368f0 100644
--- a/internal/tui/components/dialogs/models/keys.go
+++ b/internal/tui/components/dialogs/models/keys.go
@@ -2,7 +2,7 @@ package models
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
type KeyMap struct {
diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go
index 8cb19998b87891c560971ff37d734b7858a59ee6..f8d23006929fa42cfb5d1a6d2841080d2541b330 100644
--- a/internal/tui/components/dialogs/models/models.go
+++ b/internal/tui/components/dialogs/models/models.go
@@ -6,16 +6,16 @@ import (
"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"
- "github.com/opencode-ai/opencode/internal/config"
- "github.com/opencode-ai/opencode/internal/llm/models"
- "github.com/opencode-ai/opencode/internal/tui/components/completions"
- "github.com/opencode-ai/opencode/internal/tui/components/core"
- "github.com/opencode-ai/opencode/internal/tui/components/core/list"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
const (
diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go
index 426bcc6c38b03257e81088fd7a2c6534e4facb6e..12773f1ad452963364546f21161361060845811c 100644
--- a/internal/tui/components/dialogs/quit/keys.go
+++ b/internal/tui/components/dialogs/quit/keys.go
@@ -2,7 +2,7 @@ package quit
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
// KeyMap defines the keyboard bindings for the quit dialog.
diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go
index d370be34a2e5283deb37f7ea0a397d9817515671..da0d5baa76efe58c12521d7b19419aa84df2aff4 100644
--- a/internal/tui/components/dialogs/quit/quit.go
+++ b/internal/tui/components/dialogs/quit/quit.go
@@ -3,11 +3,11 @@ 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"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
const (
diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go
index 91cc069c18804e0bdde3557f9a24f54dceb9cdc8..0affd6a872251ae28a370c2bb62a007f0821be19 100644
--- a/internal/tui/components/dialogs/sessions/keys.go
+++ b/internal/tui/components/dialogs/sessions/keys.go
@@ -2,7 +2,7 @@ package sessions
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
type KeyMap struct {
diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go
index 31a8c8c2bf916db16333f5b152ac78a0e4b98d30..37c7d12d8c846a83f4a778ca87cc404a51a065f3 100644
--- a/internal/tui/components/dialogs/sessions/sessions.go
+++ b/internal/tui/components/dialogs/sessions/sessions.go
@@ -4,15 +4,15 @@ 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"
- "github.com/opencode-ai/opencode/internal/session"
- "github.com/opencode-ai/opencode/internal/tui/components/chat"
- "github.com/opencode-ai/opencode/internal/tui/components/completions"
- "github.com/opencode-ai/opencode/internal/tui/components/core"
- "github.com/opencode-ai/opencode/internal/tui/components/core/list"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
const SessionsDialogID dialogs.DialogID = "sessions"
diff --git a/internal/tui/components/image/load.go b/internal/tui/components/image/load.go
index 67308ef41be5c95aa7985366ad247674161dc7bc..25fb4bc82908b4d818efab199356d8a5b9bfe87d 100644
--- a/internal/tui/components/image/load.go
+++ b/internal/tui/components/image/load.go
@@ -3,6 +3,7 @@
package image
import (
+ "context"
"image"
"image/png"
"io"
@@ -28,8 +29,8 @@ func loadURL(url string) tea.Cmd {
var err error
if strings.HasPrefix(url, "http") {
- var resp *http.Response
- resp, err = http.Get(url)
+ var resp *http.Request
+ resp, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
r = resp.Body
} else {
r, err = os.Open(url)
diff --git a/internal/tui/components/logo/logo.go b/internal/tui/components/logo/logo.go
index 0ef19e1dd83259c389715d8cd9bcd88d7777957c..4b044c9dbd45284c72b7d03636d7399555e5f388 100644
--- a/internal/tui/components/logo/logo.go
+++ b/internal/tui/components/logo/logo.go
@@ -7,10 +7,10 @@ import (
"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"
- "github.com/opencode-ai/opencode/internal/tui/styles"
)
// letterform represents a letterform. It can be stretched horizontally by
diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go
index 09ddef9c0421a73d5c6a491e8897ea4cda673982..9951b1441bcd3a16c75689e80c25b16f90291cda 100644
--- a/internal/tui/components/logs/details.go
+++ b/internal/tui/components/logs/details.go
@@ -8,11 +8,11 @@ import (
"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"
- "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/util"
)
type DetailComponent interface {
diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go
index b36d2d967c01c2bb8c23d23de7049768fea9cb47..fa2cd9dd7d9d42afe31b18215edc48a386655051 100644
--- a/internal/tui/components/logs/table.go
+++ b/internal/tui/components/logs/table.go
@@ -7,11 +7,11 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/table"
tea "github.com/charmbracelet/bubbletea/v2"
- "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/util"
+ "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"
)
type TableComponent interface {
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index 4a9d0f81d600e8fb5701cc8555241723b2188d74..f41d5d4f328de0f2020226a5f146e7dc8a8dcaca 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -2,7 +2,7 @@ package tui
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
type KeyMap struct {
diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go
index 523540088d7b779b6f1ec0053476b5938ef354af..da13516250c57488221d7696c8fadceec15400a3 100644
--- a/internal/tui/layout/container.go
+++ b/internal/tui/layout/container.go
@@ -3,9 +3,9 @@ package layout
import (
"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"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
type Container interface {
diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go
index 88ee9051b920cf96ece4942133cda6d959c0af8d..98b656aa0661a1199e532cebbf97681f1790b723 100644
--- a/internal/tui/layout/split.go
+++ b/internal/tui/layout/split.go
@@ -3,9 +3,9 @@ package layout
import (
"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"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
type LayoutPanel string
diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go
index ce5a38a3454d26c77cb5ceb209cd7d41ac216b23..05a12a9a23c57b96a115558a820ab729269bb67f 100644
--- a/internal/tui/page/chat/chat.go
+++ b/internal/tui/page/chat/chat.go
@@ -5,17 +5,17 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
- "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/chat"
- "github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
- "github.com/opencode-ai/opencode/internal/tui/components/chat/sidebar"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/page"
- "github.com/opencode-ai/opencode/internal/tui/util"
+ "github.com/charmbracelet/crush/internal/app"
+ "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/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"
diff --git a/internal/tui/page/chat/keys.go b/internal/tui/page/chat/keys.go
index 8441e23b02fd16c70d80ad6258633b3e9756d885..8d11d4cae5297e8e6b765e841bf1e035940b707a 100644
--- a/internal/tui/page/chat/keys.go
+++ b/internal/tui/page/chat/keys.go
@@ -2,7 +2,7 @@ package chat
import (
"github.com/charmbracelet/bubbles/v2/key"
- "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/charmbracelet/crush/internal/tui/layout"
)
type KeyMap struct {
diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go
index e94fa5d12837a4d823804f8c8617ec42cc3a25ba..b66df829713e9aa5f72bd4797f36267e8cc23e7a 100644
--- a/internal/tui/page/logs.go
+++ b/internal/tui/page/logs.go
@@ -3,11 +3,11 @@ package page
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/tui/components/logs"
+ "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/opencode-ai/opencode/internal/tui/components/logs"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
var LogsPage PageID = "logs"
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 2477fb7022b3904875d3e4dc31467b9510273dcb..58405d81fa8ee94b6d369702987fd19c7d0a9d1d 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -5,24 +5,24 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/app"
+ "github.com/charmbracelet/crush/internal/logging"
+ "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/filepicker"
+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
+ "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/styles"
+ "github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/logging"
- "github.com/opencode-ai/opencode/internal/pubsub"
- cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
- "github.com/opencode-ai/opencode/internal/tui/components/completions"
- "github.com/opencode-ai/opencode/internal/tui/components/core/status"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs/filepicker"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs/models"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs/sessions"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/page"
- "github.com/opencode-ai/opencode/internal/tui/page/chat"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
)
// appModel represents the main application model that manages pages, dialogs, and UI state.
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 031ce3c9ed037cd5b34aa521f62bc9ca03b6ac5d..a5305d08d7ae3ede818568d5cf825d1ce52bbf61 100644
--- a/main.go
+++ b/main.go
@@ -6,8 +6,8 @@ import (
_ "net/http/pprof" // profiling
- "github.com/opencode-ai/opencode/cmd"
- "github.com/opencode-ai/opencode/internal/logging"
+ "github.com/charmbracelet/crush/cmd"
+ "github.com/charmbracelet/crush/internal/logging"
)
func main() {
From 5baebf702d0cf3da94cd4167636755660a875bea Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 21:09:10 +0200
Subject: [PATCH 64/73] chore: rename opencode -> crush
---
README.md | 44 +++++++++----------
cmd/root.go | 14 +++---
cmd/schema/README.md | 6 +--
cmd/schema/main.go | 10 ++---
cspell.json | 2 +-
install | 22 +++++-----
internal/config/config.go | 10 ++---
internal/db/connect.go | 2 +-
internal/fileutil/fileutil.go | 2 +-
internal/fileutil/ls.go | 2 +-
internal/llm/prompt/coder.go | 4 +-
internal/llm/provider/provider.go | 2 +-
internal/llm/tools/bash.go | 10 ++---
internal/llm/tools/fetch.go | 2 +-
internal/llm/tools/shell/shell.go | 8 ++--
internal/llm/tools/sourcegraph.go | 2 +-
internal/logging/logger.go | 2 +-
.../components/dialogs/commands/commands.go | 2 +-
.../tui/components/dialogs/commands/loader.go | 4 +-
opencode-schema.json | 10 ++---
20 files changed, 80 insertions(+), 80 deletions(-)
diff --git a/README.md b/README.md
index 39fec806a2d299dffefb039404aeae11ea37e55e..4b68dfbdb4f915dbde5c348720fa37072a3dcafd 100644
--- a/README.md
+++ b/README.md
@@ -43,17 +43,17 @@ curl -fsSL https://raw.githubusercontent.com/charmbracelet/crush/refs/heads/main
### 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
@@ -66,9 +66,9 @@ go install github.com/charmbracelet/crush@latest
OpenCode 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
@@ -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": {
@@ -248,13 +248,13 @@ OpenCode supports a variety of AI models from different providers:
```bash
# Start OpenCode
-opencode
+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
@@ -263,13 +263,13 @@ You can run OpenCode in non-interactive mode by passing a prompt directly as a c
```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.
@@ -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
@@ -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`.
@@ -614,13 +614,13 @@ You can also configure a self-hosted model in the configuration file under the `
```bash
# Clone the repository
git clone https://github.com/charmbracelet/crush.git
-cd opencode
+cd crush
# Build
-go build -o opencode
+go build -o crush
# Run
-./opencode
+./crush
```
## Acknowledgments
diff --git a/cmd/root.go b/cmd/root.go
index 9a8748f5252773176b490b172fd8c14c26e7bc12..db9cc116f2db557e4affefc1453612fd5f2a9531 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -21,29 +21,29 @@ import (
)
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.
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
diff --git a/cmd/schema/README.md b/cmd/schema/README.md
index b67626635144a4e97b49cf6f5d86808e1a2b2fac..4876ccb4d4e8513b66a03534b015b9de77eb800d 100644
--- a/cmd/schema/README.md
+++ b/cmd/schema/README.md
@@ -5,7 +5,7 @@ This tool generates a JSON Schema for the OpenCode configuration file. The schem
## 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 b638fb7a0d2b113304c4338779332bd6ad2d7bf9..4d2b9b8bffc93c3d653336baf5165320a90650bd 100644
--- a/cmd/schema/main.go
+++ b/cmd/schema/main.go
@@ -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,8 +89,8 @@ func generateSchema() map[string]any {
".cursor/rules/",
"CLAUDE.md",
"CLAUDE.local.md",
- "opencode.md",
- "opencode.local.md",
+ "crush.md",
+ "crush.local.md",
"OpenCode.md",
"OpenCode.local.md",
"OPENCODE.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/cspell.json b/cspell.json
index f59940e21add71cde463dbf18e50d40ff0c76594..afdb1e5275851972ef8d0cf2c8503fe9f2f26323 100644
--- a/cspell.json
+++ b/cspell.json
@@ -1 +1 @@
-{"flagWords":[],"language":"en","words":["opencode","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
+{"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/install b/install
index 8d394d34b930e9d9afe8c359c5a0ae52cfa00e76..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,7 +36,7 @@ case "$filename" in
;;
esac
-INSTALL_DIR=$HOME/.opencode/bin
+INSTALL_DIR=$HOME/.crush/bin
mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then
@@ -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/config/config.go b/internal/config/config.go
index 5ed55552d9d4f07c4d4e00f8d7980880d05e8a34..07e1c95fab98f84496d572f54a82406947b0589a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -97,9 +97,9 @@ type Config struct {
// Application constants
const (
- defaultDataDirectory = ".opencode"
+ defaultDataDirectory = ".crush"
defaultLogLevel = "info"
- appName = "opencode"
+ appName = "crush"
MaxTokensFallbackDefault = 4096
)
@@ -110,8 +110,8 @@ var defaultContextPaths = []string{
".cursor/rules/",
"CLAUDE.md",
"CLAUDE.local.md",
- "opencode.md",
- "opencode.local.md",
+ "crush.md",
+ "crush.local.md",
"OpenCode.md",
"OpenCode.local.md",
"OPENCODE.md",
@@ -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/db/connect.go b/internal/db/connect.go
index 3881dd34bdc16a9a893d24377eafcd1f59e7aace..ed48ddcba8fea094c815b009dcaa5ce1cc354d0c 100644
--- a/internal/db/connect.go
+++ b/internal/db/connect.go
@@ -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/fileutil/fileutil.go b/internal/fileutil/fileutil.go
index 94013b7f3e27abb9e4240d62e72176d1f8576067..b9619b8ada2cf3b9df29b30cc51238bf829ebd8e 100644
--- a/internal/fileutil/fileutil.go
+++ b/internal/fileutil/fileutil.go
@@ -67,7 +67,7 @@ func SkipHidden(path string) bool {
}
commonIgnoredDirs := map[string]bool{
- ".opencode": true,
+ ".crush": true,
"node_modules": true,
"vendor": true,
"dist": true,
diff --git a/internal/fileutil/ls.go b/internal/fileutil/ls.go
index 9ea0dfa670388f46ff339f77f03a9dd60897d2b8..9a271da25f49b48c965115ae7979c690e88bf8c1 100644
--- a/internal/fileutil/ls.go
+++ b/internal/fileutil/ls.go
@@ -64,7 +64,7 @@ var CommonIgnorePatterns = []string{
".fseventsd",
// OpenCode
- ".opencode",
+ ".crush",
}
type DirectoryLister struct {
diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go
index 085dc9bec55b2b1def04082fed66b5859c676fce..dcf3c7370f062375ee8299de71930b5f1a80b3eb 100644
--- a/internal/llm/prompt/coder.go
+++ b/internal/llm/prompt/coder.go
@@ -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.
@@ -156,7 +156,7 @@ The user will primarily request you perform software engineering tasks. This inc
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.
diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go
index 558eec31059ecb068897fe09397813bbaafe6afd..dae3bc10e6dea0ddd6b6757f34c0bd247b10e33e 100644
--- a/internal/llm/provider/provider.go
+++ b/internal/llm/provider/provider.go
@@ -130,7 +130,7 @@ 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",
+ "HTTP-Referer": "crush.ai",
"X-Title": "OpenCode",
}),
)
diff --git a/internal/llm/tools/bash.go b/internal/llm/tools/bash.go
index 33e703cca4d559ea32d97786c191dcae49855c43..d2c467fe45dccebde8aa3ac130a488dfef9c5000 100644
--- a/internal/llm/tools/bash.go
+++ b/internal/llm/tools/bash.go
@@ -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/fetch.go b/internal/llm/tools/fetch.go
index 105733dc680ef40efb57a2ecb735af1e6b1463ea..780f22a43bae7c9ec1e077c2d5878d3aeb0284ec 100644
--- a/internal/llm/tools/fetch.go
+++ b/internal/llm/tools/fetch.go
@@ -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/shell/shell.go b/internal/llm/tools/shell/shell.go
index 0f0f88afced34a53fef34cfda83aec637ca02f5b..fffe8fcfe73894f30790c6a21be402332af21c9c 100644
--- a/internal/llm/tools/shell/shell.go
+++ b/internal/llm/tools/shell/shell.go
@@ -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/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/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index 718b49599a3267151e459653fd28861db0acf24f..59b92d1ad52500cedacc9c421288207177b91cd7 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -233,7 +233,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
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'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 tea.Batch(
util.CmdHandler(chat.SendMsg{
diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go
index 447d7c6412c191934563f7351630d6832424846b..9f70afa3cd60342028b6d3fd00e017221c179686 100644
--- a/internal/tui/components/dialogs/commands/loader.go
+++ b/internal/tui/components/dialogs/commands/loader.go
@@ -56,7 +56,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
// Home directory
if home, err := os.UserHomeDir(); err == nil {
sources = append(sources, commandSource{
- path: filepath.Join(home, ".opencode", "commands"),
+ path: filepath.Join(home, ".crush", "commands"),
prefix: UserCommandPrefix,
})
}
@@ -78,7 +78,7 @@ func getXDGCommandsDir() string {
}
}
if xdgHome != "" {
- return filepath.Join(xdgHome, "opencode", "commands")
+ return filepath.Join(xdgHome, "crush", "commands")
}
return ""
}
diff --git a/opencode-schema.json b/opencode-schema.json
index dc139fda374964b1254d5df12c42751c84d29e7a..0e60c0330b5da10cee836385067fb0bfcafe9bcc 100644
--- a/opencode-schema.json
+++ b/opencode-schema.json
@@ -216,8 +216,8 @@
".cursor/rules/",
"CLAUDE.md",
"CLAUDE.local.md",
- "opencode.md",
- "opencode.local.md",
+ "crush.md",
+ "crush.local.md",
"OpenCode.md",
"OpenCode.local.md",
"OPENCODE.md",
@@ -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",
From 2ea3947d259ccbf4a47d11bd15599dbb279c43ce Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 21:09:48 +0200
Subject: [PATCH 65/73] chore: rename OpenCode -> Crush
---
OpenCode.md | 2 +-
README.md | 52 +++++++++----------
cmd/root.go | 2 +-
cmd/schema/README.md | 4 +-
cmd/schema/main.go | 8 +--
internal/config/config.go | 4 +-
internal/fileutil/ls.go | 2 +-
internal/llm/agent/mcp-tools.go | 4 +-
internal/llm/prompt/coder.go | 8 +--
internal/llm/prompt/task.go | 2 +-
internal/llm/provider/provider.go | 2 +-
internal/tui/components/dialog/init.go | 2 +-
.../components/dialogs/commands/commands.go | 4 +-
opencode-schema.json | 8 +--
14 files changed, 52 insertions(+), 52 deletions(-)
diff --git a/OpenCode.md b/OpenCode.md
index f55de8ccd00bc58596ad01e1c4b3549e9e82bf93..f869136a5a630f548f904dd137885c26dc1097de 100644
--- a/OpenCode.md
+++ b/OpenCode.md
@@ -1,4 +1,4 @@
-# OpenCode Development Guide
+# Crush Development Guide
## Build/Test/Lint Commands
diff --git a/README.md b/README.md
index 4b68dfbdb4f915dbde5c348720fa37072a3dcafd..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!

@@ -64,7 +64,7 @@ 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/.crush.json`
- `$XDG_CONFIG_HOME/crush/.crush.json`
@@ -72,7 +72,7 @@ OpenCode looks for configuration in the following locations:
### 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:
@@ -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,7 +247,7 @@ OpenCode supports a variety of AI models from different providers:
## Usage
```bash
-# Start OpenCode
+# Start Crush
crush
# Start with debug logging
@@ -259,7 +259,7 @@ 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
@@ -272,13 +272,13 @@ crush -p "Explain the use of context in Go" -f json
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
@@ -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
@@ -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
@@ -625,7 +625,7 @@ go build -o 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/cmd/root.go b/cmd/root.go
index db9cc116f2db557e4affefc1453612fd5f2a9531..0fb28c958b474dac3093deb94687bea3c9dce1b6 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -23,7 +23,7 @@ import (
var rootCmd = &cobra.Command{
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: `
diff --git a/cmd/schema/README.md b/cmd/schema/README.md
index 4876ccb4d4e8513b66a03534b015b9de77eb800d..517fdb4d20fb9f2b819051bd72e6c33f5dea2195 100644
--- a/cmd/schema/README.md
+++ b/cmd/schema/README.md
@@ -1,6 +1,6 @@
-# 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
diff --git a/cmd/schema/main.go b/cmd/schema/main.go
index 4d2b9b8bffc93c3d653336baf5165320a90650bd..0be2c041451294593e6e8b6fd66f69536e159674 100644
--- a/cmd/schema/main.go
+++ b/cmd/schema/main.go
@@ -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{},
}
@@ -91,8 +91,8 @@ func generateSchema() map[string]any {
"CLAUDE.local.md",
"crush.md",
"crush.local.md",
- "OpenCode.md",
- "OpenCode.local.md",
+ "Crush.md",
+ "Crush.local.md",
"OPENCODE.md",
"OPENCODE.local.md",
},
diff --git a/internal/config/config.go b/internal/config/config.go
index 07e1c95fab98f84496d572f54a82406947b0589a..5d0d70d470d15c8fceaa3ac6b4e6075dcd164df3 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -112,8 +112,8 @@ var defaultContextPaths = []string{
"CLAUDE.local.md",
"crush.md",
"crush.local.md",
- "OpenCode.md",
- "OpenCode.local.md",
+ "Crush.md",
+ "Crush.local.md",
"OPENCODE.md",
"OPENCODE.local.md",
}
diff --git a/internal/fileutil/ls.go b/internal/fileutil/ls.go
index 9a271da25f49b48c965115ae7979c690e88bf8c1..1c898a642a82b0b0500d354721a06f18876c4da0 100644
--- a/internal/fileutil/ls.go
+++ b/internal/fileutil/ls.go
@@ -63,7 +63,7 @@ var CommonIgnorePatterns = []string{
".Spotlight-V100",
".fseventsd",
- // OpenCode
+ // Crush
".crush",
}
diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go
index 32ce8287a257438f01fbb6f52770677cb18b3b30..55b6983d053a70ccebf56f7c6d239246acf8c317 100644
--- a/internal/llm/agent/mcp-tools.go
+++ b/internal/llm/agent/mcp-tools.go
@@ -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/prompt/coder.go b/internal/llm/prompt/coder.go
index dcf3c7370f062375ee8299de71930b5f1a80b3eb..82939a6a238bd9c8670ffdbbd8ff55af3c87305f 100644
--- a/internal/llm/prompt/coder.go
+++ b/internal/llm/prompt/coder.go
@@ -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.
@@ -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).
diff --git a/internal/llm/prompt/task.go b/internal/llm/prompt/task.go
index 1ec3c9bc82b6568158483cc2913a9b9e8c5fdc56..53fd67dc2f88928b4fbe9773db0cd1487bcd811a 100644
--- a/internal/llm/prompt/task.go
+++ b/internal/llm/prompt/task.go
@@ -7,7 +7,7 @@ import (
)
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/provider/provider.go b/internal/llm/provider/provider.go
index dae3bc10e6dea0ddd6b6757f34c0bd247b10e33e..66e806d6f756661362628750f51f5edb8649dbaa 100644
--- a/internal/llm/provider/provider.go
+++ b/internal/llm/provider/provider.go
@@ -131,7 +131,7 @@ func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption
WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
WithOpenAIExtraHeaders(map[string]string{
"HTTP-Referer": "crush.ai",
- "X-Title": "OpenCode",
+ "X-Title": "Crush",
}),
)
return &baseProvider[OpenAIClient]{
diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go
index 0abf3af80fadae6d4fad1b9154a969810286cc2a..787c1e71115f1b43adc2724e93aa356029fbffad 100644
--- a/internal/tui/components/dialog/init.go
+++ b/internal/tui/components/dialog/init.go
@@ -108,7 +108,7 @@ func (m InitDialogCmp) View() string {
explanation := t.S().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.")
+ 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(maxWidth).
diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index 59b92d1ad52500cedacc9c421288207177b91cd7..823ad2ab72d84ac89e8b10ee686ae20dd8ad17d3 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -226,9 +226,9 @@ func (c *commandDialogCmp) defaultCommands() []Command {
{
ID: "init",
Title: "Initialize Project",
- Description: "Create/Update the OpenCode.md memory file",
+ Description: "Create/Update the Crush.md memory file",
Handler: func(cmd Command) tea.Cmd {
- prompt := `Please analyze this codebase and create a OpenCode.md file containing:
+ 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.
diff --git a/opencode-schema.json b/opencode-schema.json
index 0e60c0330b5da10cee836385067fb0bfcafe9bcc..3a035482e22817f747859e71527c6d4c585c2552 100644
--- a/opencode-schema.json
+++ b/opencode-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": {
@@ -218,8 +218,8 @@
"CLAUDE.local.md",
"crush.md",
"crush.local.md",
- "OpenCode.md",
- "OpenCode.local.md",
+ "Crush.md",
+ "Crush.local.md",
"OPENCODE.md",
"OPENCODE.local.md"
],
@@ -397,6 +397,6 @@
"type": "string"
}
},
- "title": "OpenCode Configuration",
+ "title": "Crush Configuration",
"type": "object"
}
From 7f6fb492c11107f4fd69106a987777b3375936e5 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 21:10:26 +0200
Subject: [PATCH 66/73] chore: rename OPENCODE -> CRUSH
---
.gitignore | 4 ++--
cmd/schema/main.go | 4 ++--
opencode-schema.json => crush-schema.json | 4 ++--
OpenCode.md => crush.md | 0
internal/config/config.go | 6 +++---
5 files changed, 9 insertions(+), 9 deletions(-)
rename opencode-schema.json => crush-schema.json (99%)
rename OpenCode.md => crush.md (100%)
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/cmd/schema/main.go b/cmd/schema/main.go
index 0be2c041451294593e6e8b6fd66f69536e159674..dd49d7256d93fa485d71ad66840f1cbe711e93b0 100644
--- a/cmd/schema/main.go
+++ b/cmd/schema/main.go
@@ -93,8 +93,8 @@ func generateSchema() map[string]any {
"crush.local.md",
"Crush.md",
"Crush.local.md",
- "OPENCODE.md",
- "OPENCODE.local.md",
+ "CRUSH.md",
+ "CRUSH.local.md",
},
}
diff --git a/opencode-schema.json b/crush-schema.json
similarity index 99%
rename from opencode-schema.json
rename to crush-schema.json
index 3a035482e22817f747859e71527c6d4c585c2552..16287a73c5931ed3cb50fb90a36aff9746819919 100644
--- a/opencode-schema.json
+++ b/crush-schema.json
@@ -220,8 +220,8 @@
"crush.local.md",
"Crush.md",
"Crush.local.md",
- "OPENCODE.md",
- "OPENCODE.local.md"
+ "CRUSH.md",
+ "CRUSH.local.md"
],
"description": "Context paths for the application",
"items": {
diff --git a/OpenCode.md b/crush.md
similarity index 100%
rename from OpenCode.md
rename to crush.md
diff --git a/internal/config/config.go b/internal/config/config.go
index 5d0d70d470d15c8fceaa3ac6b4e6075dcd164df3..1d7786a1dd2eef536ee0e014b47c93ecbd50fa9d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -114,8 +114,8 @@ var defaultContextPaths = []string{
"crush.local.md",
"Crush.md",
"Crush.local.md",
- "OPENCODE.md",
- "OPENCODE.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
From 86680142ac6252c80a987674945afed8403ef0c9 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sat, 7 Jun 2025 21:31:50 +0200
Subject: [PATCH 67/73] chore: rename .opencode.json -> .crush.json
---
.opencode.json => .crush.json | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename .opencode.json => .crush.json (100%)
diff --git a/.opencode.json b/.crush.json
similarity index 100%
rename from .opencode.json
rename to .crush.json
From ebcc9ae4163572be44c527787728c384fd8680eb Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Sun, 8 Jun 2025 10:23:34 +0200
Subject: [PATCH 68/73] refactor: initial permissions refactor
---
internal/fileutil/fileutil.go | 11 +-
.../tui/components/chat/messages/renderer.go | 26 +-
internal/tui/components/chat/messages/tool.go | 10 +-
.../components/dialogs/permissions/keys.go | 70 +++
.../dialogs/permissions/permissions.go | 490 ++++++++++++++++++
internal/tui/components/dialogs/quit/quit.go | 4 +-
internal/tui/styles/crush.go | 2 +
internal/tui/styles/theme.go | 2 +
internal/tui/tui.go | 30 ++
9 files changed, 617 insertions(+), 28 deletions(-)
create mode 100644 internal/tui/components/dialogs/permissions/keys.go
create mode 100644 internal/tui/components/dialogs/permissions/permissions.go
diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go
index b9619b8ada2cf3b9df29b30cc51238bf829ebd8e..92fc9d39c585f7784c7fe8ca21a0cf8d6958cbcb 100644
--- a/internal/fileutil/fileutil.go
+++ b/internal/fileutil/fileutil.go
@@ -67,7 +67,7 @@ func SkipHidden(path string) bool {
}
commonIgnoredDirs := map[string]bool{
- ".crush": true,
+ ".crush": true,
"node_modules": true,
"vendor": true,
"dist": true,
@@ -202,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/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go
index eda0565d26c1113d1b856b51cc254fde4dac1bc6..339aa51b299d368a5d8f3c31b0c10d6d00a8a784 100644
--- a/internal/tui/components/chat/messages/renderer.go
+++ b/internal/tui/components/chat/messages/renderer.go
@@ -3,12 +3,11 @@ package messages
import (
"encoding/json"
"fmt"
- "os"
"strings"
"time"
- "github.com/charmbracelet/crush/internal/config"
"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"
@@ -210,7 +209,7 @@ func (vr viewRenderer) Render(v *toolCallCmp) string {
return vr.renderError(v, "Invalid view parameters")
}
- file := prettyPath(params.FilePath)
+ file := fileutil.PrettyPath(params.FilePath)
args := newParamBuilder().
addMain(file).
addKeyValue("limit", formatNonZero(params.Limit)).
@@ -250,7 +249,7 @@ func (er editRenderer) Render(v *toolCallCmp) string {
return er.renderError(v, "Invalid edit parameters")
}
- file := prettyPath(params.FilePath)
+ file := fileutil.PrettyPath(params.FilePath)
args := newParamBuilder().addMain(file).build()
return er.renderWithParams(v, "Edit", args, func() string {
@@ -281,7 +280,7 @@ func (wr writeRenderer) Render(v *toolCallCmp) string {
return wr.renderError(v, "Invalid write parameters")
}
- file := prettyPath(params.FilePath)
+ file := fileutil.PrettyPath(params.FilePath)
args := newParamBuilder().addMain(file).build()
return wr.renderWithParams(v, "Write", args, func() string {
@@ -411,6 +410,7 @@ func (lr lsRenderer) Render(v *toolCallCmp) string {
if path == "" {
path = "."
}
+ path = fileutil.PrettyPath(path)
args := newParamBuilder().addMain(path).build()
@@ -637,6 +637,7 @@ func renderPlainContent(v *toolCallCmp, content string) string {
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")
@@ -652,6 +653,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
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)))
}
@@ -679,11 +681,6 @@ func (v *toolCallCmp) renderToolError() string {
return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
}
-func removeWorkingDirPrefix(path string) string {
- wd := config.WorkingDirectory()
- return strings.TrimPrefix(path, wd)
-}
-
func truncateHeight(s string, h int) string {
lines := strings.Split(s, "\n")
if len(lines) > h {
@@ -692,15 +689,6 @@ func truncateHeight(s string, h int) string {
return s
}
-func prettyPath(path string) string {
- // replace home directory with ~
- homeDir, err := os.UserHomeDir()
- if err == nil {
- path = strings.ReplaceAll(path, homeDir, "~")
- }
- return path
-}
-
func prettifyToolName(name string) string {
switch name {
case agent.AgentToolName:
diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go
index 94bff77c4c9fb85a72a5f8230bf38edba303c8c7..cbc5548904217b3ea14595eaf676229dd0b54bc1 100644
--- a/internal/tui/components/chat/messages/tool.go
+++ b/internal/tui/components/chat/messages/tool.go
@@ -220,6 +220,9 @@ func (m *toolCallCmp) renderPending() string {
func (m *toolCallCmp) style() lipgloss.Style {
t := styles.CurrentTheme()
+ if m.isNested {
+ return t.S().Muted
+ }
return t.S().Muted.PaddingLeft(4)
}
@@ -275,12 +278,7 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
// 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 {
- if !m.call.Finished {
- return true
- } else if m.result.ToolCallID != m.call.ID {
- return true
- }
- return false
+ return !m.call.Finished
}
// Spinning returns whether the tool call is currently showing a loading animation
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..fdf0f6a7c43a7d7c74a9ea02f477c444d9f455da
--- /dev/null
+++ b/internal/tui/components/dialogs/permissions/permissions.go
@@ -0,0 +1,490 @@
+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()
+
+ allowStyle := t.S().Text
+ allowSessionStyle := allowStyle
+ denyStyle := allowStyle
+
+ // Style the selected button
+ switch p.selectedOption {
+ case 0:
+ allowStyle = allowStyle.Foreground(t.White).Background(t.Secondary)
+ allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
+ denyStyle = denyStyle.Background(t.BgSubtle)
+ case 1:
+ allowStyle = allowStyle.Background(t.BgSubtle)
+ allowSessionStyle = allowSessionStyle.Foreground(t.White).Background(t.Secondary)
+ denyStyle = denyStyle.Background(t.BgSubtle)
+ case 2:
+ allowStyle = allowStyle.Background(t.BgSubtle)
+ allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
+ denyStyle = denyStyle.Foreground(t.White).Background(t.Secondary)
+ }
+
+ baseStyle := t.S().Base
+
+ allowMessage := fmt.Sprintf("%s%s", allowStyle.Underline(true).Render("A"), allowStyle.Render("llow"))
+ allowButton := allowStyle.Padding(0, 2).Render(allowMessage)
+ allowSessionMessage := fmt.Sprintf("%s%s%s", allowSessionStyle.Render("Allow for "), allowSessionStyle.Underline(true).Render("S"), allowSessionStyle.Render("ession"))
+ allowSessionButton := allowSessionStyle.Padding(0, 2).Render(allowSessionMessage)
+ denyMessage := fmt.Sprintf("%s%s", denyStyle.Underline(true).Render("D"), denyStyle.Render("eny"))
+ denyButton := denyStyle.Padding(0, 2).Render(denyMessage)
+
+ content := lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ allowButton,
+ " ",
+ allowSessionButton,
+ " ",
+ denyButton,
+ )
+
+ 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/quit.go b/internal/tui/components/dialogs/quit/quit.go
index da0d5baa76efe58c12521d7b19419aa84df2aff4..9f57afac7d609212d82999aa2e57fb0c13ca5d28 100644
--- a/internal/tui/components/dialogs/quit/quit.go
+++ b/internal/tui/components/dialogs/quit/quit.go
@@ -74,10 +74,10 @@ func (q *quitDialogCmp) View() tea.View {
noStyle := yesStyle
if q.selectedNo {
- noStyle = noStyle.Background(t.Secondary)
+ noStyle = noStyle.Foreground(t.White).Background(t.Secondary)
yesStyle = yesStyle.Background(t.BgSubtle)
} else {
- yesStyle = yesStyle.Background(t.Secondary)
+ yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary)
noStyle = noStyle.Background(t.BgSubtle)
}
diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go
index 618e0cb496664d18a01a82e3ffe46a9dd6ea7fdf..b35008c2a65e9c8b19ec456515d0a72823185c0b 100644
--- a/internal/tui/styles/crush.go
+++ b/internal/tui/styles/crush.go
@@ -36,6 +36,8 @@ func NewCrushTheme() *Theme {
Info: charmtone.Malibu,
// Colors
+ White: charmtone.Butter,
+
Blue: charmtone.Malibu,
Green: charmtone.Julep,
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
index 79a56959ca178f34ad974777e095e91285380911..bb3a11aa554062964d81bf37bca00c6f1220d8ca 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -50,6 +50,8 @@ type Theme struct {
Info color.Color
// Colors
+ // White
+ White color.Color
// Blues
Blue color.Color
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 58405d81fa8ee94b6d369702987fd19c7d0a9d1d..2e81b0fdbeecff9d1e637ea6dd4a383d9f9093b3 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -6,7 +6,9 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/app"
+ "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"
@@ -15,6 +17,7 @@ import (
"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
"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"
@@ -60,6 +63,7 @@ func (a appModel) Init() tea.Cmd {
// Update handles incoming messages and updates the application state.
func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ logging.Info("TUI Update", "msg", msg)
var cmds []tea.Cmd
var cmd tea.Cmd
@@ -142,7 +146,33 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, util.CmdHandler(dialogs.OpenDialogMsg{
Model: filepicker.NewFilePickerCmp(),
})
+ // Permissions
+ case pubsub.Event[permission.PermissionRequest]:
+ return a, util.CmdHandler(dialogs.OpenDialogMsg{
+ Model: permissions.NewPermissionDialogCmp(msg.Payload),
+ })
+ case permissions.PermissionResponseMsg:
+ switch msg.Action {
+ case permissions.PermissionAllow:
+ a.app.Permissions.Grant(msg.Permission)
+ case permissions.PermissionAllowForSession:
+ a.app.Permissions.GrantPersistent(msg.Permission)
+ case permissions.PermissionDeny:
+ a.app.Permissions.Deny(msg.Permission)
+ }
+ return a, nil
+ // 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, a.handleKeyPressMsg(msg)
}
s, _ := a.status.Update(msg)
From 6e526f2e5989a02569814e2c2d55983393fc4cb2 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Mon, 9 Jun 2025 09:45:02 +0200
Subject: [PATCH 69/73] refactor: new init dialog
---
crush.md | 34 +-
internal/config/init.go | 32 ++
internal/tui/components/core/helpers.go | 55 ++
internal/tui/components/dialog/init.go | 181 -------
internal/tui/components/dialog/permission.go | 507 ------------------
internal/tui/components/dialogs/init/init.go | 179 +++++++
internal/tui/components/dialogs/init/keys.go | 59 ++
.../dialogs/permissions/permissions.go | 53 +-
internal/tui/tui.go | 47 ++
9 files changed, 407 insertions(+), 740 deletions(-)
delete mode 100644 internal/tui/components/dialog/init.go
delete mode 100644 internal/tui/components/dialog/permission.go
create mode 100644 internal/tui/components/dialogs/init/init.go
create mode 100644 internal/tui/components/dialogs/init/keys.go
diff --git a/crush.md b/crush.md
index f869136a5a630f548f904dd137885c26dc1097de..d3ddbd6691c5fe6ffcd63c3aca2a36882a4caefc 100644
--- a/crush.md
+++ b/crush.md
@@ -1,22 +1,22 @@
# Crush Development Guide
## Build/Test/Lint Commands
-
-- **Build**: `go build ./...` or `go build .` (for main binary)
-- **Test**: `task test` or `go test ./...`
-- **Single test**: `go test ./internal/path/to/package -run TestName`
-- **Lint**: `task lint` or `golangci-lint run`
-- **Format**: `task fmt` or `gofumpt -w .`
+- **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**: Standard library first, then third-party, then internal packages (separated by blank lines)
-- **Types**: Use `any` instead of `interface{}`, prefer concrete types over interfaces when possible
-- **Naming**: Use camelCase for private, PascalCase for public, descriptive names (e.g., `messageListCmp`, `handleNewUserMessage`)
-- **Constants**: Use `const` blocks with descriptive names (e.g., `NotFound = -1`)
-- **Error handling**: Always check errors, use `require.NoError()` in tests, return errors up the stack
-- **Documentation**: Add comments for all public types/methods, explain complex logic in private methods
-- **Testing**: Use testify/assert and testify/require, table-driven tests with `t.Run()`, mark helpers with `t.Helper()`
-- **File organization**: Group related functionality, extract helper methods for complex logic, use meaningful method names
-- **TUI components**: Implement interfaces (util.Model, layout.Sizeable), document component purpose and behavior
-- **Message handling**: Use pubsub events, handle different message roles (User/Assistant/Tool), manage tool calls separately
+- **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/internal/config/init.go b/internal/config/init.go
index 5f8860f5264aaf0002ad782595f505c0e881b049..1221f348cc69a064e6e95e910127241980e2941c 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 {
diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go
index eda256e2a82b54099d92ee9caea6c9e6c05d6088..74a1feef003d878cc8d71db736c0b4969561a3dc 100644
--- a/internal/tui/components/core/helpers.go
+++ b/internal/tui/components/core/helpers.go
@@ -76,3 +76,58 @@ func Status(ops StatusOpts, width int) string {
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/dialog/init.go b/internal/tui/components/dialog/init.go
deleted file mode 100644
index 787c1e71115f1b43adc2724e93aa356029fbffad..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/init.go
+++ /dev/null
@@ -1,181 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
-
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/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.KeyPressMsg:
- 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 := styles.CurrentTheme()
- baseStyle := t.S().Base
-
- // 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 := t.S().Text.
- Width(maxWidth).
- Padding(0, 1).
- 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(maxWidth).
- Padding(1, 1).
- Render("Would you like to initialize this project?")
-
- maxWidth = min(maxWidth, m.width-10)
- yesStyle := t.S().Text
- noStyle := yesStyle
-
- if m.selected == 0 {
- yesStyle = yesStyle.
- Background(t.Primary).
- Bold(true)
- noStyle = noStyle.
- Background(t.BgSubtle)
- } else {
- noStyle = noStyle.
- Background(t.Primary).
- Bold(true)
- yesStyle = yesStyle.
- Background(t.BgSubtle)
- }
-
- 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()).
- BorderForeground(t.BorderFocus).
- 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/permission.go b/internal/tui/components/dialog/permission.go
deleted file mode 100644
index 7ecc923e494d4680085ba33459d65dc0516c0539..0000000000000000000000000000000000000000
--- a/internal/tui/components/dialog/permission.go
+++ /dev/null
@@ -1,507 +0,0 @@
-package dialog
-
-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/llm/tools"
- "github.com/charmbracelet/crush/internal/permission"
- "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 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 {
- util.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.KeyPressMsg:
- 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 := styles.CurrentTheme()
-
- allowStyle := t.S().Text
- allowSessionStyle := allowStyle
- denyStyle := allowStyle
-
- // Style the selected button
- switch p.selectedOption {
- case 0:
- allowStyle = allowStyle.Background(t.Secondary)
- allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
- denyStyle = denyStyle.Background(t.BgSubtle)
- case 1:
- allowStyle = allowStyle.Background(t.BgSubtle)
- allowSessionStyle = allowSessionStyle.Background(t.Secondary)
- denyStyle = denyStyle.Background(t.BgSubtle)
- case 2:
- allowStyle = allowStyle.Background(t.BgSubtle)
- allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
- denyStyle = denyStyle.Background(t.Secondary)
- }
-
- 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,
- " ",
- allowSessionButton,
- " ",
- denyButton,
- " ",
- )
-
- remainingWidth := p.width - lipgloss.Width(content)
- if remainingWidth > 0 {
- content = strings.Repeat(" ", remainingWidth) + content
- }
- return content
-}
-
-func (p *permissionDialogCmp) renderHeader() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base
-
- toolKey := t.S().Muted.Bold(true).Render("Tool")
- toolValue := t.S().Text.
- Width(p.width - lipgloss.Width(toolKey)).
- Render(fmt.Sprintf(": %s", p.permission.ToolName))
-
- pathKey := t.S().Muted.Bold(true).Render("Path")
- pathValue := t.S().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, t.S().Muted.Width(p.width).Bold(true).Render("Command"))
- case tools.EditToolName:
- params := p.permission.Params.(tools.EditPermissionsParams)
- fileKey := t.S().Muted.Bold(true).Render("File")
- filePath := t.S().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 := t.S().Muted.Bold(true).Render("File")
- filePath := t.S().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, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
- }
-
- return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-}
-
-func (p *permissionDialogCmp) renderBashContent() string {
- baseStyle := styles.CurrentTheme().S().Base
- 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 s, 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 {
- baseStyle := styles.CurrentTheme().S().Base
- 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 s, err
- })
-
- finalContent := baseStyle.
- Width(p.contentViewPort.Width()).
- Render(renderedContent)
- p.contentViewPort.SetContent(finalContent)
- return p.styleViewport()
- }
- return ""
-}
-
-func (p *permissionDialogCmp) renderDefaultContent() string {
- baseStyle := styles.CurrentTheme().S().Base
-
- 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 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 := 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.SetHeight(p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title))
- 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()
- }
-
- 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()).
- BorderForeground(t.BorderFocus).
- Width(p.width).
- Height(p.height).
- Render(
- content,
- )
-}
-
-func (p *permissionDialogCmp) View() tea.View {
- return tea.NewView(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()
-
- 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/dialogs/init/init.go b/internal/tui/components/dialogs/init/init.go
new file mode 100644
index 0000000000000000000000000000000000000000..da792695e984454bc2439fd47fe940e655d843b2
--- /dev/null
+++ b/internal/tui/components/dialogs/init/init.go
@@ -0,0 +1,179 @@
+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/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{}),
+ util.CmdHandler(CloseInitDialogMsg{Initialize: 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{}),
+ util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0}),
+ )
+ case key.Matches(msg, m.keyMap.Y):
+ return m, tea.Batch(
+ util.CmdHandler(dialogs.CloseDialogMsg{}),
+ util.CmdHandler(CloseInitDialogMsg{Initialize: true}),
+ )
+ case key.Matches(msg, m.keyMap.N):
+ return m, tea.Batch(
+ util.CmdHandler(dialogs.CloseDialogMsg{}),
+ util.CmdHandler(CloseInitDialogMsg{Initialize: 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
+}
+
+// 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/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go
index fdf0f6a7c43a7d7c74a9ea02f477c444d9f455da..0e69eaccc89237bc6ca4bf7fe694a9f48b8d524c 100644
--- a/internal/tui/components/dialogs/permissions/permissions.go
+++ b/internal/tui/components/dialogs/permissions/permissions.go
@@ -133,44 +133,27 @@ func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
func (p *permissionDialogCmp) renderButtons() string {
t := styles.CurrentTheme()
+ baseStyle := t.S().Base
- allowStyle := t.S().Text
- allowSessionStyle := allowStyle
- denyStyle := allowStyle
-
- // Style the selected button
- switch p.selectedOption {
- case 0:
- allowStyle = allowStyle.Foreground(t.White).Background(t.Secondary)
- allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
- denyStyle = denyStyle.Background(t.BgSubtle)
- case 1:
- allowStyle = allowStyle.Background(t.BgSubtle)
- allowSessionStyle = allowSessionStyle.Foreground(t.White).Background(t.Secondary)
- denyStyle = denyStyle.Background(t.BgSubtle)
- case 2:
- allowStyle = allowStyle.Background(t.BgSubtle)
- allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
- denyStyle = denyStyle.Foreground(t.White).Background(t.Secondary)
+ 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,
+ },
}
- baseStyle := t.S().Base
-
- allowMessage := fmt.Sprintf("%s%s", allowStyle.Underline(true).Render("A"), allowStyle.Render("llow"))
- allowButton := allowStyle.Padding(0, 2).Render(allowMessage)
- allowSessionMessage := fmt.Sprintf("%s%s%s", allowSessionStyle.Render("Allow for "), allowSessionStyle.Underline(true).Render("S"), allowSessionStyle.Render("ession"))
- allowSessionButton := allowSessionStyle.Padding(0, 2).Render(allowSessionMessage)
- denyMessage := fmt.Sprintf("%s%s", denyStyle.Underline(true).Render("D"), denyStyle.Render("eny"))
- denyButton := denyStyle.Padding(0, 2).Render(denyMessage)
-
- content := lipgloss.JoinHorizontal(
- lipgloss.Left,
- allowButton,
- " ",
- allowSessionButton,
- " ",
- denyButton,
- )
+ content := core.SelectableButtons(buttons, " ")
return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 2e81b0fdbeecff9d1e637ea6dd4a383d9f9093b3..a41b72bdbe2d0ad991ff9f2376948f202c9004b4 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -6,6 +6,7 @@ import (
"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/tools"
"github.com/charmbracelet/crush/internal/logging"
"github.com/charmbracelet/crush/internal/permission"
@@ -16,6 +17,7 @@ import (
"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/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"
@@ -58,6 +60,24 @@ func (a appModel) Init() tea.Cmd {
cmd = a.status.Init()
cmds = append(cmds, cmd)
+
+ // Check if we should show the init dialog
+ cmds = append(cmds, func() tea.Msg {
+ shouldShow, err := config.ShouldShowInitDialog()
+ if err != nil {
+ return util.InfoMsg{
+ Type: util.InfoTypeError,
+ Msg: "Failed to check init status: " + err.Error(),
+ }
+ }
+ if shouldShow {
+ return dialogs.OpenDialogMsg{
+ Model: initDialog.NewInitDialogCmp(),
+ }
+ }
+ return nil
+ })
+
return tea.Batch(cmds...)
}
@@ -161,6 +181,33 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Permissions.Deny(msg.Permission)
}
return a, nil
+ // Init Dialog
+ case initDialog.CloseInitDialogMsg:
+ if msg.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 a, util.ReportError(err)
+ }
+
+ return a, util.CmdHandler(cmpChat.SendMsg{
+ Text: prompt,
+ })
+ } else {
+ // Mark the project as initialized without running the command
+ if err := config.MarkProjectInitialized(); err != nil {
+ return a, util.ReportError(err)
+ }
+ }
+ return a, nil
// Key Press Messages
case tea.KeyPressMsg:
if msg.String() == "ctrl+t" {
From b744b0f444aa94454f1c8bee6c4f3a03255b86b9 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Mon, 9 Jun 2025 11:49:54 +0200
Subject: [PATCH 70/73] refactor: new compact dialog
---
internal/llm/agent/agent.go | 26 +-
internal/llm/provider/anthropic.go | 3 +-
.../components/dialogs/commands/commands.go | 45 +--
.../tui/components/dialogs/compact/compact.go | 266 ++++++++++++++++++
.../tui/components/dialogs/compact/keys.go | 61 ++++
internal/tui/components/dialogs/init/init.go | 42 ++-
internal/tui/tui.go | 62 ++--
7 files changed, 443 insertions(+), 62 deletions(-)
create mode 100644 internal/tui/components/dialogs/compact/compact.go
create mode 100644 internal/tui/components/dialogs/compact/keys.go
diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go
index 9120c76aff8d5efa7161b4fab73577d31991e07a..c19451e1d0ef46597b8e3f9d56f9e0ebdf4362cb 100644
--- a/internal/llm/agent/agent.go
+++ b/internal/llm/agent/agent.go
@@ -586,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,
@@ -651,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/provider/anthropic.go b/internal/llm/provider/anthropic.go
index 77edc8e0519e6f82b0c807626dfebbcd5c09d3a4..f5f627c228f5708307980efdcaf9e35a8a9f48c8 100644
--- a/internal/llm/provider/anthropic.go
+++ b/internal/llm/provider/anthropic.go
@@ -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 {
@@ -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/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go
index 823ad2ab72d84ac89e8b10ee686ae20dd8ad17d3..b140fc1246d36e806836359f5b17030e5b383a1b 100644
--- a/internal/tui/components/dialogs/commands/commands.go
+++ b/internal/tui/components/dialogs/commands/commands.go
@@ -50,14 +50,18 @@ type commandDialogCmp struct {
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() CommandsDialog {
+func NewCommandDialog(sessionID string) CommandsDialog {
listKeyMap := list.DefaultKeyMap()
keyMap := DefaultCommandsDialogKeyMap()
@@ -87,6 +91,7 @@ func NewCommandDialog() CommandsDialog {
keyMap: DefaultCommandsDialogKeyMap(),
help: help,
commandType: SystemCommands,
+ sessionID: sessionID,
}
}
@@ -222,7 +227,7 @@ func (c *commandDialogCmp) Position() (int, int) {
}
func (c *commandDialogCmp) defaultCommands() []Command {
- return []Command{
+ commands := []Command{
{
ID: "init",
Title: "Initialize Project",
@@ -235,33 +240,35 @@ func (c *commandDialogCmp) defaultCommands() []Command {
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 tea.Batch(
- util.CmdHandler(chat.SendMsg{
- Text: prompt,
- }),
- )
+ 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 func() tea.Msg {
- // TODO: implement compact message
- return ""
- }
+ 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 func() tea.Msg {
- return SwitchSessionsMsg{}
- }
+ return util.CmdHandler(SwitchSessionsMsg{})
},
},
{
@@ -269,12 +276,10 @@ func (c *commandDialogCmp) defaultCommands() []Command {
Title: "Switch Model",
Description: "Switch to a different model",
Handler: func(cmd Command) tea.Cmd {
- return func() tea.Msg {
- return SwitchModelMsg{}
- }
+ return util.CmdHandler(SwitchModelMsg{})
},
},
- }
+ }...)
}
func (c *commandDialogCmp) ID() dialogs.DialogID {
diff --git a/internal/tui/components/dialogs/compact/compact.go b/internal/tui/components/dialogs/compact/compact.go
new file mode 100644
index 0000000000000000000000000000000000000000..afa7c8945009fb7b76d979e466cae290757f3f27
--- /dev/null
+++ b/internal/tui/components/dialogs/compact/compact.go
@@ -0,0 +1,266 @@
+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..0f176927a173ec44db9ec85a9f476723f0cb4b94
--- /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,
+ }
+}
\ No newline at end of file
diff --git a/internal/tui/components/dialogs/init/init.go b/internal/tui/components/dialogs/init/init.go
index da792695e984454bc2439fd47fe940e655d843b2..ff4cbfb4d7b6933523cc873019758c9203ff8657 100644
--- a/internal/tui/components/dialogs/init/init.go
+++ b/internal/tui/components/dialogs/init/init.go
@@ -5,6 +5,8 @@ import (
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"
@@ -51,7 +53,7 @@ func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keyMap.Close):
return m, tea.Batch(
util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(CloseInitDialogMsg{Initialize: false}),
+ m.handleInitialization(false),
)
case key.Matches(msg, m.keyMap.ChangeSelection):
m.selected = (m.selected + 1) % 2
@@ -59,17 +61,17 @@ func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keyMap.Select):
return m, tea.Batch(
util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0}),
+ m.handleInitialization(m.selected == 0),
)
case key.Matches(msg, m.keyMap.Y):
return m, tea.Batch(
util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(CloseInitDialogMsg{Initialize: true}),
+ m.handleInitialization(true),
)
case key.Matches(msg, m.keyMap.N):
return m, tea.Batch(
util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(CloseInitDialogMsg{Initialize: false}),
+ m.handleInitialization(false),
)
}
}
@@ -168,6 +170,38 @@ func (m *initDialogCmp) Position() (int, int) {
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
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index a41b72bdbe2d0ad991ff9f2376948f202c9004b4..87f140838f224368fbabe59af47d1933b15312be 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -7,6 +7,7 @@ import (
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"
@@ -16,6 +17,7 @@ import (
"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"
@@ -157,6 +159,12 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
@@ -181,33 +189,35 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Permissions.Deny(msg.Permission)
}
return a, nil
- // Init Dialog
- case initDialog.CloseInitDialogMsg:
- if msg.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 a, util.ReportError(err)
- }
-
- return a, util.CmdHandler(cmpChat.SendMsg{
- Text: prompt,
- })
- } else {
- // Mark the project as initialized without running the command
- if err := config.MarkProjectInitialized(); err != nil {
- return a, util.ReportError(err)
+ // Agent Events
+ case pubsub.Event[agent.AgentEvent]:
+ payload := msg.Payload
+
+ // 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)
+ }
+
+ // 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),
+ }))
+ }
}
}
- return a, nil
+
+ return a, tea.Batch(cmds...)
// Key Press Messages
case tea.KeyPressMsg:
if msg.String() == "ctrl+t" {
@@ -296,7 +306,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
return util.CmdHandler(dialogs.CloseDialogMsg{})
}
return util.CmdHandler(dialogs.OpenDialogMsg{
- Model: commands.NewCommandDialog(),
+ Model: commands.NewCommandDialog(a.selectedSessionID),
})
case key.Matches(msg, a.keyMap.Sessions):
if a.dialog.ActiveDialogId() == sessions.SessionsDialogID {
From f44035ea80fe213874063f9af6aa5a6a807b3e2b Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Mon, 9 Jun 2025 12:19:06 +0200
Subject: [PATCH 71/73] feat: add attachments to messages
---
internal/tui/components/chat/editor/editor.go | 62 ++++++++-----------
internal/tui/components/core/status/status.go | 2 +-
.../dialogs/filepicker/filepicker.go | 46 +++++++++++++-
internal/tui/page/chat/chat.go | 12 +++-
internal/tui/tui.go | 3 +-
5 files changed, 83 insertions(+), 42 deletions(-)
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index d8ae8d71d6dfe4038c73fe6e0bd1b686c0c071e5..0eb63fb9e836de1ff5c45bf7e91a4dcc12309e08 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -18,6 +18,7 @@ import (
"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"
@@ -141,13 +142,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
- // 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 = ""
@@ -351,7 +352,24 @@ func (m *editorCmp) startCompletions() tea.Msg {
}
}
-func CreateTextArea(existing *textarea.Model) textarea.Model {
+// Blur implements Container.
+func (c *editorCmp) Blur() tea.Cmd {
+ c.textarea.Blur()
+ return nil
+}
+
+// Focus implements Container.
+func (c *editorCmp) Focus() tea.Cmd {
+ logging.Info("Focusing editor textarea")
+ 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)
@@ -369,36 +387,8 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
ta.CharLimit = -1
ta.Placeholder = "Tell me more about this project..."
ta.SetVirtualCursor(false)
-
- if existing != nil {
- ta.SetValue(existing.Value())
- ta.SetWidth(existing.Width())
- ta.SetHeight(existing.Height())
- }
-
ta.Focus()
- return ta
-}
-// Blur implements Container.
-func (c *editorCmp) Blur() tea.Cmd {
- c.textarea.Blur()
- return nil
-}
-
-// Focus implements Container.
-func (c *editorCmp) Focus() tea.Cmd {
- logging.Info("Focusing editor textarea")
- return c.textarea.Focus()
-}
-
-// IsFocused implements Container.
-func (c *editorCmp) IsFocused() bool {
- return c.textarea.Focused()
-}
-
-func NewEditorCmp(app *app.App) util.Model {
- ta := CreateTextArea(nil)
return &editorCmp{
app: app,
textarea: ta,
diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go
index 796d2edf634a08d1b3fbf42d67c0ff818de59b75..ef5ebef108e9252aefa353fddefdde3538a28497 100644
--- a/internal/tui/components/core/status/status.go
+++ b/internal/tui/components/core/status/status.go
@@ -88,7 +88,7 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m statusCmp) View() tea.View {
t := styles.CurrentTheme()
- status := t.S().Base.Padding(0, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
+ 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:
diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go
index 6b67e309e66c4455835c2315062c6c4f9081a169..916209b6f6371b7c5961f9fbc507f9c680f9e59b 100644
--- a/internal/tui/components/dialogs/filepicker/filepicker.go
+++ b/internal/tui/components/dialogs/filepicker/filepicker.go
@@ -1,13 +1,18 @@
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"
@@ -23,7 +28,7 @@ const (
)
type FilePickedMsg struct {
- FilePath string
+ Attachment message.Attachment
}
type FilePicker interface {
@@ -111,7 +116,31 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Get the path of the selected file.
return m, tea.Sequence(
util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(FilePickedMsg{FilePath: path}),
+ 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)
@@ -185,3 +214,16 @@ func (m *model) Position() (int, int) {
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/page/chat/chat.go b/internal/tui/page/chat/chat.go
index 05a12a9a23c57b96a115558a820ab729269bb67f..4a501f658e9f5f2b0a1367b11d34c6304c983a48 100644
--- a/internal/tui/page/chat/chat.go
+++ b/internal/tui/page/chat/chat.go
@@ -6,6 +6,8 @@ import (
"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/logging"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/session"
@@ -87,7 +89,15 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
)
case key.Matches(msg, p.keyMap.FilePicker):
- return p, util.CmdHandler(OpenFilePickerMsg{})
+ 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):
logging.Info("Tab key pressed, toggling chat focus")
if p.session.ID == "" {
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 87f140838f224368fbabe59af47d1933b15312be..c14d93bd392c8dd44496efc1a42a8e0d905bb7f6 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -85,7 +85,6 @@ func (a appModel) Init() tea.Cmd {
// Update handles incoming messages and updates the application state.
func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- logging.Info("TUI Update", "msg", msg)
var cmds []tea.Cmd
var cmd tea.Cmd
@@ -248,7 +247,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// handleWindowResize processes window resize events and updates all components.
func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
var cmds []tea.Cmd
- msg.Height -= 1 // Make space for the status bar
+ msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
// Update status bar
From 3b840ada2e1c35d2831df7e49571ad5d8269bd64 Mon Sep 17 00:00:00 2001
From: Kujtim Hoxha
Date: Mon, 9 Jun 2025 15:57:07 +0200
Subject: [PATCH 72/73] chore: initial logs changes
---
internal/config/init.go | 4 +-
internal/tui/components/chat/editor/editor.go | 1 -
internal/tui/components/core/helpers.go | 26 +--
.../tui/components/dialogs/compact/compact.go | 1 -
.../tui/components/dialogs/compact/keys.go | 2 +-
internal/tui/components/logs/details.go | 91 +++++++---
internal/tui/components/logs/table.go | 155 +++++++++++++-----
internal/tui/page/chat/chat.go | 2 -
internal/tui/page/logs/keys.go | 37 +++++
internal/tui/page/{ => logs}/logs.go | 48 ++++--
internal/tui/tui.go | 8 +-
todos.md | 9 +-
12 files changed, 270 insertions(+), 114 deletions(-)
create mode 100644 internal/tui/page/logs/keys.go
rename internal/tui/page/{ => logs}/logs.go (55%)
diff --git a/internal/config/init.go b/internal/config/init.go
index 1221f348cc69a064e6e95e910127241980e2941c..df9f213f15ddd4e85f912e3c121276a7da28ac09 100644
--- a/internal/config/init.go
+++ b/internal/config/init.go
@@ -63,13 +63,13 @@ func crushMdExists(dir string) (bool, error) {
if entry.IsDir() {
continue
}
-
+
name := strings.ToLower(entry.Name())
if name == "crush.md" {
return true, nil
}
}
-
+
return false, nil
}
diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go
index 0eb63fb9e836de1ff5c45bf7e91a4dcc12309e08..d0b936349db07598a33de8bd48238e16c6cb9524 100644
--- a/internal/tui/components/chat/editor/editor.go
+++ b/internal/tui/components/chat/editor/editor.go
@@ -360,7 +360,6 @@ func (c *editorCmp) Blur() tea.Cmd {
// Focus implements Container.
func (c *editorCmp) Focus() tea.Cmd {
- logging.Info("Focusing editor textarea")
return c.textarea.Focus()
}
diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go
index 74a1feef003d878cc8d71db736c0b4969561a3dc..e586b0563278080eb85c7e0bbaa4dbee86e670e9 100644
--- a/internal/tui/components/core/helpers.go
+++ b/internal/tui/components/core/helpers.go
@@ -78,39 +78,39 @@ func Status(ops StatusOpts, width int) string {
}
type ButtonOpts struct {
- Text string
- UnderlineIndex int // Index of character to underline (0-based)
- Selected bool // Whether this button is selected
+ 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) +
+
+ 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)
}
@@ -120,7 +120,7 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
if spacing == "" {
spacing = " "
}
-
+
var parts []string
for i, button := range buttons {
parts = append(parts, SelectableButton(button))
@@ -128,6 +128,6 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
parts = append(parts, spacing)
}
}
-
+
return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
}
diff --git a/internal/tui/components/dialogs/compact/compact.go b/internal/tui/components/dialogs/compact/compact.go
index afa7c8945009fb7b76d979e466cae290757f3f27..895053279ff916b113051aca3eeb1652ec82936e 100644
--- a/internal/tui/components/dialogs/compact/compact.go
+++ b/internal/tui/components/dialogs/compact/compact.go
@@ -263,4 +263,3 @@ func (c *compactDialogCmp) Position() (int, int) {
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
index 0f176927a173ec44db9ec85a9f476723f0cb4b94..3d15d3e4caad747fbd2511ce09f5f4ce994236b6 100644
--- a/internal/tui/components/dialogs/compact/keys.go
+++ b/internal/tui/components/dialogs/compact/keys.go
@@ -58,4 +58,4 @@ func (k KeyMap) ShortHelp() []key.Binding {
k.Select,
k.Close,
}
-}
\ No newline at end of file
+}
diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go
index 9951b1441bcd3a16c75689e80c25b16f90291cda..82bec5a32fb840624f63cc326672174f2c8b0d4f 100644
--- a/internal/tui/components/logs/details.go
+++ b/internal/tui/components/logs/details.go
@@ -52,43 +52,55 @@ func (i *detailCmp) updateContent() {
var content strings.Builder
t := styles.CurrentTheme()
- // Format the header with timestamp and level
- timeStyle := t.S().Muted
+ 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 := t.S().Text.Bold(true)
- 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 := t.S().Text.Bold(true)
- 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 := t.S().Base.Foreground(t.Primary).Bold(true)
- valueStyle := t.S().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")
}
}
@@ -102,20 +114,48 @@ func getLevelStyle(level string) lipgloss.Style {
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.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() tea.View {
- return tea.NewView(i.viewport.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) {
@@ -123,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.SetWidth(i.width)
- i.viewport.SetHeight(i.height)
+ i.viewport.SetWidth(i.width - 4)
+ i.viewport.SetHeight(i.height - 4)
i.updateContent()
return nil
}
diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go
index fa2cd9dd7d9d42afe31b18215edc48a386655051..d7671ba84304e971134472a78576605454a214c4 100644
--- a/internal/tui/components/logs/table.go
+++ b/internal/tui/components/logs/table.go
@@ -1,8 +1,9 @@
package logs
import (
- "encoding/json"
+ "fmt"
"slices"
+ "strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/table"
@@ -12,6 +13,7 @@ import (
"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 {
@@ -22,48 +24,80 @@ type TableComponent interface {
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() 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 tea.NewView(i.table.View())
}
@@ -75,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
}
@@ -91,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/page/chat/chat.go b/internal/tui/page/chat/chat.go
index 4a501f658e9f5f2b0a1367b11d34c6304c983a48..6ca1ac1e91d42b0896cfa3f8dc0b723ca53b4063 100644
--- a/internal/tui/page/chat/chat.go
+++ b/internal/tui/page/chat/chat.go
@@ -8,7 +8,6 @@ import (
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/models"
- "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"
@@ -99,7 +98,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
}
case key.Matches(msg, p.keyMap.Tab):
- logging.Info("Tab key pressed, toggling chat focus")
if p.session.ID == "" {
return p, nil
}
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.go b/internal/tui/page/logs/logs.go
similarity index 55%
rename from internal/tui/page/logs.go
rename to internal/tui/page/logs/logs.go
index b66df829713e9aa5f72bd4797f36267e8cc23e7a..5b86fb325beed3d33e866ec6d268610d3f58016c 100644
--- a/internal/tui/page/logs.go
+++ b/internal/tui/page/logs/logs.go
@@ -1,26 +1,30 @@
-package page
+package logs
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/components/logs"
+ "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 PageID = "logs"
+var LogsPage page.PageID = "logs"
type LogPage interface {
util.Model
layout.Sizeable
- layout.Bindings
}
+
type logsPage struct {
width, height int
- table layout.Container
- details layout.Container
+ table logsComponents.TableComponent
+ details logsComponents.DetailComponent
+ keyMap KeyMap
}
func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -30,34 +34,39 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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.(layout.Container)
+ p.table = table.(logsComponents.TableComponent)
details, cmd := p.details.Update(msg)
cmds = append(cmds, cmd)
- p.details = details.(layout.Container)
+ p.details = details.(logsComponents.DetailComponent)
return p, tea.Batch(cmds...)
}
func (p *logsPage) View() tea.View {
- style := styles.CurrentTheme().S().Base.Width(p.width).Height(p.height)
+ 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,
- p.table.View().String(),
+ title,
p.details.View().String(),
+ p.table.View().String(),
),
),
)
}
-func (p *logsPage) BindingKeys() []key.Binding {
- return p.table.BindingKeys()
-}
-
// GetSize implements LogPage.
func (p *logsPage) GetSize() (int, int) {
return p.width, p.height
@@ -67,9 +76,11 @@ func (p *logsPage) GetSize() (int, int) {
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, height/2),
- p.details.SetSize(width, height/2),
+ p.table.SetSize(width-2, availableHeight/2),
+ p.details.SetSize(width-2, availableHeight/2),
)
}
@@ -82,7 +93,8 @@ func (p *logsPage) Init() tea.Cmd {
func NewLogsPage() LogPage {
return &logsPage{
- table: layout.NewContainer(logs.NewLogsTable(), layout.WithBorderAll()),
- details: layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderAll()),
+ details: logsComponents.NewLogsDetails(),
+ table: logsComponents.NewLogsTable(),
+ keyMap: DefaultKeyMap(),
}
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index c14d93bd392c8dd44496efc1a42a8e0d905bb7f6..e2c037a586aaad777412372b79b5596f22d569f4 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -27,6 +27,7 @@ import (
"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"
@@ -137,7 +138,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, statusCmd)
// If the current page is logs, update the logs view
- if a.currentPage == page.LogsPage {
+ if a.currentPage == logs.LogsPage {
updated, pageCmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(util.Model)
cmds = append(cmds, pageCmd)
@@ -328,7 +329,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
return tea.Sequence(cmds...)
// Page navigation
case key.Matches(msg, a.keyMap.Logs):
- return a.moveToPage(page.LogsPage)
+ return a.moveToPage(logs.LogsPage)
default:
if a.dialog.HasDialogs() {
@@ -379,7 +380,6 @@ func (a *appModel) View() tea.View {
lipgloss.NewLayer(appView),
}
if a.dialog.HasDialogs() {
- logging.Info("Rendering dialogs")
layers = append(
layers,
a.dialog.GetLayers()...,
@@ -424,7 +424,7 @@ func New(app *app.App) tea.Model {
pages: map[page.PageID]util.Model{
chat.ChatPage: chat.NewChatPage(app),
- page.LogsPage: page.NewLogsPage(),
+ logs.LogsPage: logs.NewLogsPage(),
},
dialog: dialogs.NewDialogCmp(),
diff --git a/todos.md b/todos.md
index b6c3853b6f44c70bf20851efba3496c09c1c641f..0397c1088aae74a99a4ccf1e2ec4bc12c3be0477 100644
--- a/todos.md
+++ b/todos.md
@@ -11,9 +11,9 @@
- [x] Cleanup Commands
- [x] Sessions dialog
-- [ ] Models
-- [~] Move sessions and model dialog to the commands
-- [ ] Add sessions shortuct
+- [x] Models
+- [x] Move sessions and model dialog to the commands
+- [x] Add sessions shortuct
- [ ] Add all posible actions to the commands
## Investigate
@@ -24,3 +24,6 @@
## 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
From bc790a9e7df47b401323aced0231d8adaa3ccd4c Mon Sep 17 00:00:00 2001
From: Ayman Bagabas
Date: Mon, 9 Jun 2025 10:36:10 -0400
Subject: [PATCH 73/73] chore: bump bubbletea to latest v2-viewable commit
---
go.mod | 2 +-
go.sum | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/go.mod b/go.mod
index 8123d187820eb1b4873428b7e61249b55c960ff1..295e588a228ddbf51b640deef9455aef014fb6f3 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
github.com/bmatcuk/doublestar/v4 v4.8.1
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.20250602154956-43689cfc0174
+ 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
diff --git a/go.sum b/go.sum
index 7af16ab0ae6cbea3532270af03847e6e747cfdff..bc037b574b8bce18c668c507aaec84f2e6cf17f4 100644
--- a/go.sum
+++ b/go.sum
@@ -72,6 +72,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e
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=