Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
400623f117 | ||
|
|
4b047d3dab | ||
|
|
550d2d3f99 | ||
|
|
149f133747 | ||
|
|
ff23f67ad5 | ||
|
|
8f0644e35b | ||
|
|
3fdd23df16 | ||
|
|
2c82ee592c | ||
|
|
1ad529db59 | ||
|
|
96866e52ce | ||
|
|
507c975e92 | ||
|
|
3e69d5276b | ||
|
|
289a4d9b18 | ||
|
|
12bf5f641d | ||
|
|
2051e85e96 | ||
|
|
12b86829d9 | ||
|
|
6c9ec54129 | ||
|
|
b7b0cdbd7c | ||
|
|
fd98c3189a | ||
|
|
1278353616 | ||
|
|
638ec7bc50 | ||
|
|
38ae7d60aa | ||
|
|
2d1f9fc321 | ||
|
|
ee0c8132db | ||
|
|
c2208fa1f9 | ||
|
|
bf42d8b011 | ||
|
|
0deb85fa45 | ||
|
|
da19b10703 | ||
|
|
80b17dab44 | ||
|
|
6d2ffa82de | ||
|
|
7998c3b5ce | ||
|
|
13def91e9a | ||
|
|
26a40610dd | ||
|
|
db2fbed691 | ||
|
|
3d4c1425d9 | ||
|
|
10c8b49590 | ||
|
|
500cea5ce7 | ||
|
|
5aafab118f | ||
|
|
01f8d3b05d | ||
|
|
99d6a28249 | ||
|
|
5eaf7ab586 | ||
|
|
e4f754eee7 | ||
|
|
f20ef61bc7 | ||
|
|
5611ef8b28 | ||
|
|
bec796e3c3 | ||
|
|
0bd8b2c72f | ||
|
|
5550ce47e1 | ||
|
|
2d84dadc0c | ||
|
|
45c0578b22 | ||
|
|
1ded535175 | ||
|
|
d957ab849b | ||
|
|
4b2e52c834 | ||
|
|
6867658c0f | ||
|
|
b8620395cb | ||
|
|
90d37c98f8 | ||
|
|
c9a40917c2 | ||
|
|
0aa0e740cd | ||
|
|
bb17d14665 | ||
|
|
cd0b2ae032 | ||
|
|
8e8796507d | ||
|
|
cef5c29583 | ||
|
|
acaed1f270 | ||
|
|
cda0dbc195 | ||
|
|
758425a8e4 | ||
|
|
93446df335 | ||
|
|
adc8b90e0f | ||
|
|
733c9903ec | ||
|
|
7306e20361 | ||
|
|
b4c7042c17 | ||
|
|
6965787b33 | ||
|
|
ce064b8b0e | ||
|
|
0fc546fc6b | ||
|
|
77ac9e5ec2 | ||
|
|
af2c0b3695 | ||
|
|
811b22367d | ||
|
|
933d50e25a | ||
|
|
800bee2722 | ||
|
|
5b4fb96c2e | ||
|
|
1d20bf343d | ||
|
|
79d9bf57f7 | ||
|
|
7b63db6a13 | ||
|
|
0e1565449e | ||
|
|
f9a47fe5a3 | ||
|
|
2bf9d5d4ec | ||
|
|
c18f9ece69 | ||
|
|
4e3c73c4f5 | ||
|
|
8bf2eeccd0 | ||
|
|
6232e0fc58 | ||
|
|
a8b4aed446 | ||
|
|
03de0c406d | ||
|
|
faf8da8743 | ||
|
|
3386908fd6 | ||
|
|
5a8847952a | ||
|
|
87d21ebf2b | ||
|
|
a524fc545c | ||
|
|
4316edaf43 | ||
|
|
d845924e8b | ||
|
|
a29b322bdd | ||
|
|
9723ffa7a6 | ||
|
|
f06cd88773 | ||
|
|
9af92b6914 | ||
|
|
8f64c4b312 | ||
|
|
a32877e908 | ||
|
|
6465c9c44a | ||
|
|
4699739814 | ||
|
|
c1d87c32a2 | ||
|
|
9c5d9be33a | ||
|
|
97d9c851e6 | ||
|
|
76bd702992 | ||
|
|
50c453e577 | ||
|
|
86d5b25d18 | ||
|
|
2b44dbdbf1 | ||
|
|
4bbbbac5f6 | ||
|
|
3c3a997d2a | ||
|
|
1676f8b5dd | ||
|
|
c87a7469a0 | ||
|
|
132e26ddbf | ||
|
|
f1da70b1de | ||
|
|
5c9d1910af | ||
|
|
18abcab208 | ||
|
|
01e7dc2d02 | ||
|
|
611854e4b6 | ||
|
|
d56dec4ba7 | ||
|
|
c952e9ae3d | ||
|
|
6470243095 | ||
|
|
c8321cfbd9 | ||
|
|
46c246e01f | ||
|
|
9964d8e6c0 | ||
|
|
df33143396 | ||
|
|
571aeaaea2 | ||
|
|
edfea03917 | ||
|
|
81c88cc742 | ||
|
|
99b9390d80 | ||
|
|
23c30521d8 | ||
|
|
e681d610de | ||
|
|
a1fdeded3e | ||
|
|
2051312d12 | ||
|
|
20cb7a76af | ||
|
|
a493aec174 | ||
|
|
3ce3ac8e61 | ||
|
|
91ad64feda | ||
|
|
60b55f9d92 | ||
|
|
3c6c2bf13b | ||
|
|
d4f9375548 | ||
|
|
28b39f547e | ||
|
|
7520f5efa8 | ||
|
|
eb4cdf4b20 | ||
|
|
9f6fc1c3c5 | ||
|
|
dfede9ae6e | ||
|
|
fc45c0c944 | ||
|
|
9d869f784c | ||
|
|
bd244f73af | ||
|
|
dd34556e9c | ||
|
|
f7dd48e60d | ||
|
|
93c779cf48 | ||
|
|
360c04c542 | ||
|
|
529fd57e75 | ||
|
|
faea3777e1 | ||
|
|
a4664e2344 | ||
|
|
cdc1d8a94d | ||
|
|
fdd6d6600f | ||
|
|
9f44cfd595 | ||
|
|
70229b150c | ||
|
|
050ff943a6 | ||
|
|
88b58fd6a0 | ||
|
|
5d67e13df5 | ||
|
|
57d1a60efc | ||
|
|
add81b9739 | ||
|
|
81bdb8e269 | ||
|
|
a563fdd287 | ||
|
|
7c93bf5993 | ||
|
|
6a5a4247c6 | ||
|
|
a39136a2a0 | ||
|
|
9f5b59f336 | ||
|
|
01c125b058 | ||
|
|
d41aa2bc72 | ||
|
|
f45deb37f0 | ||
|
|
e89972a396 | ||
|
|
c3c647a21a | ||
|
|
b79167ce66 | ||
|
|
7ac0a2bc65 | ||
|
|
cb032cff2b | ||
|
|
867a69a751 | ||
|
|
20b8efcc50 | ||
|
|
a86d42149f | ||
|
|
82a36acfe3 | ||
|
|
0793c3f2a3 | ||
|
|
5c860b0d69 | ||
|
|
05bb127a8e | ||
|
|
1bbd84008f | ||
|
|
fdfd4d69d3 | ||
|
|
7f659cce36 | ||
|
|
48fcaa83be | ||
|
|
70c16c4c95 | ||
|
|
c1e1ef6eb5 | ||
|
|
bb155db8b2 | ||
|
|
7c91f668d1 | ||
|
|
1af103d29e | ||
|
|
8a3e581edc | ||
|
|
749e7838a4 | ||
|
|
73b46c2bf9 | ||
|
|
8bd250fb15 | ||
|
|
b1ab641905 | ||
|
|
76e256ed64 | ||
|
|
4f955f2127 | ||
|
|
bbeb579d3a | ||
|
|
f707fb3f8d | ||
|
|
6b98acb7be | ||
|
|
2487b18f62 | ||
|
|
533f64fe26 | ||
|
|
b5c85d3806 | ||
|
|
bcf952bc8a | ||
|
|
a6dc75a44c | ||
|
|
416daca9c6 | ||
|
|
636fe0fb64 | ||
|
|
95e0957d64 | ||
|
|
2eefdae6a9 | ||
|
|
d62746ceb7 | ||
|
|
4b2ce14ff3 | ||
|
|
294a11752e | ||
|
|
1cf1d1f634 | ||
|
|
2ce694d41f | ||
|
|
d6eff3b3a3 | ||
|
|
e63a6d45c1 | ||
|
|
93686519ba | ||
|
|
f593792fb5 | ||
|
|
2cdb37c32b | ||
|
|
535d79b64c | ||
|
|
b4e4c3f662 | ||
|
|
ba676e7ae0 | ||
|
|
a1c8e5af45 | ||
|
|
f1e7e7c138 | ||
|
|
80b77caec0 | ||
|
|
86a2ea44b5 | ||
|
|
a2002c88c6 | ||
|
|
d8bcf4f4e7 | ||
|
|
31e0326f78 | ||
|
|
a53d2ea356 | ||
|
|
229a280652 | ||
|
|
8d0350d923 | ||
|
|
4192d7eacc | ||
|
|
7b8b4cf8c7 | ||
|
|
1f4de75348 | ||
|
|
457755c690 | ||
|
|
052a1e7514 | ||
|
|
139d6e2818 | ||
|
|
06554efdf4 | ||
|
|
67e9bda94f | ||
|
|
53bb6b4c4f | ||
|
|
73d54c7068 | ||
|
|
90d6c4ab41 | ||
|
|
736396fc70 | ||
|
|
177bfed93e | ||
|
|
91f8477ef5 | ||
|
|
f04a5e50ee | ||
|
|
bb28b70700 | ||
|
|
7361a02ef3 | ||
|
|
d465f150fc | ||
|
|
17fa8c117b | ||
|
|
9aa0c40a00 | ||
|
|
fd4648da17 | ||
|
|
aadca5013a | ||
|
|
5c3d490e59 | ||
|
|
1254f48135 |
@@ -0,0 +1,14 @@
|
||||
name: discord
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published] # fires only when a release is published
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send nicely-formatted embed to Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
@@ -0,0 +1,24 @@
|
||||
name: opencode
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
if: startsWith(github.event.comment.body, 'hey opencode')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run opencode
|
||||
uses: sst/opencode/sdks/github@github-v1
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
with:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
@@ -0,0 +1,30 @@
|
||||
name: publish-github-action
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "github-v*.*.*"
|
||||
- "!github-v1"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
git config --global user.email "opencode@sst.dev"
|
||||
git config --global user.name "opencode"
|
||||
./script/publish
|
||||
working-directory: ./sdks/github
|
||||
@@ -0,0 +1,36 @@
|
||||
name: publish-vscode
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "vscode-v*.*.*"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.17
|
||||
|
||||
- run: git fetch --force --tags
|
||||
- run: bun install -g @vscode/vsce
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
bun install
|
||||
./script/publish
|
||||
working-directory: ./sdks/vscode
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OPENVSX_TOKEN: ${{ secrets.OPENVSX_TOKEN }}
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
- dev
|
||||
tags:
|
||||
- "*"
|
||||
- "!vscode-v*"
|
||||
- "!github-v*"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
## Style
|
||||
|
||||
- prefer single word variable/function names
|
||||
- avoid try catch where possible - prefer to let exceptions bubble up
|
||||
- avoid else statements where possible
|
||||
- do not make useless helper functions - inline functionality unless the
|
||||
function is reusable or composable
|
||||
- prefer Bun apis
|
||||
|
||||
## Workflow
|
||||
|
||||
- you can regenerate the golang sdk by calling ./scripts/stainless.ts
|
||||
- we use bun for everything
|
||||
@@ -30,7 +30,23 @@ brew install sst/tap/opencode # macOS
|
||||
paru -S opencode-bin # Arch Linux
|
||||
```
|
||||
|
||||
> **Note:** Remove versions older than 0.1.x before installing
|
||||
> [!TIP]
|
||||
> Remove versions older than 0.1.x before installing.
|
||||
|
||||
#### Installation Directory
|
||||
|
||||
The install script respects the following priority order for the installation path:
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
|
||||
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
|
||||
3. `$HOME/bin` - Standard user binary directory (if exists or can be created)
|
||||
4. `$HOME/.opencode/bin` - Default fallback
|
||||
|
||||
```bash
|
||||
# Examples
|
||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
@@ -38,10 +54,25 @@ For more info on how to configure opencode [**head over to our docs**](https://o
|
||||
|
||||
### Contributing
|
||||
|
||||
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
|
||||
opencode is an opinionated tool so any fundamental feature needs to go through a
|
||||
design process with the core team.
|
||||
|
||||
> **Note**: Please talk to us via github issues before spending time working on
|
||||
> a new feature
|
||||
> [!IMPORTANT]
|
||||
> We do not accept PRs for core features.
|
||||
|
||||
However we still merge a ton of PRs - you can contribute:
|
||||
|
||||
- Bug fixes
|
||||
- Improvements to LLM performance
|
||||
- Support for new providers
|
||||
- Fixes for env specific quirks
|
||||
- Missing standard behavior
|
||||
- Documentation
|
||||
|
||||
Take a look at the git history to see what kind of PRs we end up merging.
|
||||
|
||||
> [!NOTE]
|
||||
> If you do not follow the above guidelines we might close your PR.
|
||||
|
||||
To run opencode locally you need.
|
||||
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | --------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | ---------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
module github.com/sst/opencode
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/alecthomas/chroma/v2 v2.18.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
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/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.8
|
||||
golang.org/x/image v0.28.0
|
||||
rsc.io/qr v0.2.0
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/charmbracelet/x/input => ./packages/tui/input
|
||||
)
|
||||
|
||||
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/input v0.3.7 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/getkin/kin-openapi v0.127.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/goccy/go-yaml v1.17.1 // indirect
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sanity-io/litter v1.5.8 // indirect
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
||||
github.com/spf13/cobra v1.9.1 // indirect
|
||||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // 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.20250505150409-97991a1f17d1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap 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
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.26.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/atombender/go-jsonschema
|
||||
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||
)
|
||||
@@ -20,6 +20,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
|
||||
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/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/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-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
|
||||
@@ -48,7 +48,7 @@ if [ -z "$requested_version" ]; then
|
||||
url="https://github.com/sst/opencode/releases/latest/download/$filename"
|
||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||
echo "${RED}Failed to fetch version information${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -186,4 +186,3 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
||||
echo "$INSTALL_DIR" >> $GITHUB_PATH
|
||||
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
|
||||
+19
-16
@@ -1,25 +1,28 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"openrouter": {
|
||||
"models": {
|
||||
"moonshotai/kimi-k2": {
|
||||
"options": {
|
||||
"provider": {
|
||||
"order": ["baseten"],
|
||||
"allow_fallbacks": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"huggingface": {
|
||||
"models": {
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507:fireworks-ai": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"weather": {
|
||||
"type": "local",
|
||||
"command": ["opencode", "x", "@h1deya/mcp-server-weather"]
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"hook": {
|
||||
"file_edited": {
|
||||
".json": [
|
||||
{
|
||||
"command": ["bun", "run", "prettier", "$FILE"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"session_completed": [
|
||||
{
|
||||
"command": ["touch", "./node_modules/foo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -18,7 +18,7 @@
|
||||
"typescript": "5.8.2",
|
||||
"@types/node": "22.13.9",
|
||||
"zod": "3.25.49",
|
||||
"ai": "5.0.0-beta.7"
|
||||
"ai": "5.0.0-beta.21"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39,7 +39,5 @@
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"ai@4.3.16": "patches/ai@4.3.16.patch"
|
||||
}
|
||||
"patchedDependencies": {}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
flag "github.com/spf13/pflag"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode-sdk-go/option"
|
||||
"github.com/sst/opencode/internal/api"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
@@ -50,11 +52,37 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
slog.Error("Failed to stat stdin", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check if there's data piped to stdin
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
stdin, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read stdin", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
stdinContent := strings.TrimSpace(string(stdin))
|
||||
if stdinContent != "" {
|
||||
if prompt == nil || *prompt == "" {
|
||||
prompt = &stdinContent
|
||||
} else {
|
||||
combined := *prompt + "\n" + stdinContent
|
||||
prompt = &combined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
httpClient := opencode.NewClient(
|
||||
option.WithBaseURL(url),
|
||||
)
|
||||
|
||||
apiHandler := util.NewAPILogHandler(httpClient, "tui", slog.LevelDebug)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
apiHandler := util.NewAPILogHandler(ctx, httpClient, "tui", slog.LevelDebug)
|
||||
logger := slog.New(apiHandler)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
@@ -68,9 +96,6 @@ func main() {
|
||||
}()
|
||||
|
||||
// Create main context for the application
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -79,7 +104,6 @@ func main() {
|
||||
program := tea.NewProgram(
|
||||
tui.NewModel(app_),
|
||||
tea.WithAltScreen(),
|
||||
// tea.WithKeyboardEnhancements(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
@@ -91,6 +115,9 @@ func main() {
|
||||
stream := httpClient.Event.ListStreaming(ctx)
|
||||
for stream.Next() {
|
||||
evt := stream.Current().AsUnion()
|
||||
if _, ok := evt.(opencode.EventListResponseEventStorageWrite); ok {
|
||||
continue
|
||||
}
|
||||
program.Send(evt)
|
||||
}
|
||||
if err := stream.Err(); err != nil {
|
||||
@@ -99,6 +126,8 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
go api.Start(ctx, program, httpClient)
|
||||
|
||||
// Handle signals in a separate goroutine
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
@@ -10,7 +10,6 @@ require (
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
|
||||
github.com/charmbracelet/x/ansi v0.9.3
|
||||
github.com/charmbracelet/x/input v0.3.7
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lithammer/fuzzysearch v1.1.8
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
@@ -24,7 +23,7 @@ require (
|
||||
|
||||
replace (
|
||||
github.com/charmbracelet/x/input => ./input
|
||||
github.com/sst/opencode-sdk-go => ./sdk
|
||||
github.com/sst/opencode/packages/sdk/go => ../sdk/go
|
||||
)
|
||||
|
||||
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
@@ -34,6 +33,7 @@ require (
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/input v0.3.7 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
@@ -0,0 +1,315 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
|
||||
github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
|
||||
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
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/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-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
|
||||
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=
|
||||
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 v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
|
||||
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
|
||||
github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
|
||||
github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
|
||||
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
||||
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
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/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/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
|
||||
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
|
||||
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.8 h1:Tp7nbckbMCwAA/ieVZeeZCp79xXtrPMaWLRk5mhNwrw=
|
||||
github.com/sst/opencode-sdk-go v0.1.0-alpha.8/go.mod h1:uagorfAHZsVy6vf0xY6TlQraM4uCILdZ5tKKhl1oToM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
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.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20211216021012-1d35b9e2eb4e/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/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-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
@@ -31,42 +31,20 @@ type win32InputState struct {
|
||||
|
||||
// Reader represents an input event reader. It reads input events and parses
|
||||
// escape sequences from the terminal input buffer and translates them into
|
||||
// human-readable events.
|
||||
// human‑readable events.
|
||||
type Reader struct {
|
||||
rd cancelreader.CancelReader
|
||||
table map[string]Key // table is a lookup table for key sequences.
|
||||
|
||||
term string // term is the terminal name $TERM.
|
||||
|
||||
// paste is the bracketed paste mode buffer.
|
||||
// When nil, bracketed paste mode is disabled.
|
||||
paste []byte
|
||||
|
||||
buf [256]byte // do we need a larger buffer?
|
||||
|
||||
// partialSeq holds incomplete escape sequences that need more data
|
||||
partialSeq []byte
|
||||
|
||||
// keyState keeps track of the current Windows Console API key events state.
|
||||
// It is used to decode ANSI escape sequences and utf16 sequences.
|
||||
keyState win32InputState
|
||||
|
||||
parser Parser
|
||||
logger Logger
|
||||
rd cancelreader.CancelReader
|
||||
table map[string]Key // table is a lookup table for key sequences.
|
||||
term string // $TERM
|
||||
paste []byte // bracketed paste buffer; nil when disabled
|
||||
buf [256]byte // read buffer
|
||||
partialSeq []byte // holds incomplete escape sequences
|
||||
keyState win32InputState
|
||||
parser Parser
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// NewReader returns a new input event reader. The reader reads input events
|
||||
// from the terminal and parses escape sequences into human-readable events. It
|
||||
// supports reading Terminfo databases. See [Parser] for more information.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// r, _ := input.NewReader(os.Stdin, os.Getenv("TERM"), 0)
|
||||
// defer r.Close()
|
||||
// events, _ := r.ReadEvents()
|
||||
// for _, ev := range events {
|
||||
// log.Printf("%v", ev)
|
||||
// }
|
||||
// NewReader returns a new input event reader.
|
||||
func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
|
||||
d := new(Reader)
|
||||
cr, err := newCancelreader(r, flags)
|
||||
@@ -82,46 +60,38 @@ func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
|
||||
}
|
||||
|
||||
// SetLogger sets a logger for the reader.
|
||||
func (d *Reader) SetLogger(l Logger) {
|
||||
d.logger = l
|
||||
}
|
||||
func (d *Reader) SetLogger(l Logger) { d.logger = l }
|
||||
|
||||
// Read implements [io.Reader].
|
||||
func (d *Reader) Read(p []byte) (int, error) {
|
||||
return d.rd.Read(p) //nolint:wrapcheck
|
||||
}
|
||||
// Read implements io.Reader.
|
||||
func (d *Reader) Read(p []byte) (int, error) { return d.rd.Read(p) }
|
||||
|
||||
// Cancel cancels the underlying reader.
|
||||
func (d *Reader) Cancel() bool {
|
||||
return d.rd.Cancel()
|
||||
}
|
||||
func (d *Reader) Cancel() bool { return d.rd.Cancel() }
|
||||
|
||||
// Close closes the underlying reader.
|
||||
func (d *Reader) Close() error {
|
||||
return d.rd.Close() //nolint:wrapcheck
|
||||
}
|
||||
func (d *Reader) Close() error { return d.rd.Close() }
|
||||
|
||||
func (d *Reader) readEvents() ([]Event, error) {
|
||||
nb, err := d.rd.Read(d.buf[:])
|
||||
if err != nil {
|
||||
return nil, err //nolint:wrapcheck
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var events []Event
|
||||
|
||||
// Combine any partial sequence from previous read with new data
|
||||
// Combine any partial sequence from previous read with new data.
|
||||
var buf []byte
|
||||
if len(d.partialSeq) > 0 {
|
||||
buf = make([]byte, len(d.partialSeq)+nb)
|
||||
copy(buf, d.partialSeq)
|
||||
copy(buf[len(d.partialSeq):], d.buf[:nb])
|
||||
d.partialSeq = nil // clear the partial sequence
|
||||
d.partialSeq = nil
|
||||
} else {
|
||||
buf = d.buf[:nb]
|
||||
}
|
||||
|
||||
// Lookup table first
|
||||
if bytes.HasPrefix(buf, []byte{'\x1b'}) {
|
||||
// Fast path: direct lookup for simple escape sequences.
|
||||
if bytes.HasPrefix(buf, []byte{0x1b}) {
|
||||
if k, ok := d.table[string(buf)]; ok {
|
||||
if d.logger != nil {
|
||||
d.logger.Printf("input: %q", buf)
|
||||
@@ -133,24 +103,23 @@ func (d *Reader) readEvents() ([]Event, error) {
|
||||
|
||||
var i int
|
||||
for i < len(buf) {
|
||||
nb, ev := d.parser.parseSequence(buf[i:])
|
||||
if d.logger != nil && nb > 0 {
|
||||
d.logger.Printf("input: %q", buf[i:i+nb])
|
||||
consumed, ev := d.parser.parseSequence(buf[i:])
|
||||
if d.logger != nil && consumed > 0 {
|
||||
d.logger.Printf("input: %q", buf[i:i+consumed])
|
||||
}
|
||||
|
||||
// Handle incomplete sequences - when parseSequence returns (0, nil)
|
||||
// it means we need more data to complete the sequence
|
||||
if nb == 0 && ev == nil {
|
||||
// Store the remaining data for the next read
|
||||
remaining := len(buf) - i
|
||||
if remaining > 0 {
|
||||
d.partialSeq = make([]byte, remaining)
|
||||
// Incomplete sequence – store remainder and exit.
|
||||
if consumed == 0 && ev == nil {
|
||||
rem := len(buf) - i
|
||||
if rem > 0 {
|
||||
d.partialSeq = make([]byte, rem)
|
||||
copy(d.partialSeq, buf[i:])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Handle bracketed-paste
|
||||
// Handle bracketed paste specially so we don’t emit a paste event for
|
||||
// every byte.
|
||||
if d.paste != nil {
|
||||
if _, ok := ev.(PasteEndEvent); !ok {
|
||||
d.paste = append(d.paste, buf[i])
|
||||
@@ -160,15 +129,9 @@ func (d *Reader) readEvents() ([]Event, error) {
|
||||
}
|
||||
|
||||
switch ev.(type) {
|
||||
// case UnknownEvent:
|
||||
// // If the sequence is not recognized by the parser, try looking it up.
|
||||
// if k, ok := d.table[string(buf[i:i+nb])]; ok {
|
||||
// ev = KeyPressEvent(k)
|
||||
// }
|
||||
case PasteStartEvent:
|
||||
d.paste = []byte{}
|
||||
case PasteEndEvent:
|
||||
// Decode the captured data into runes.
|
||||
var paste []rune
|
||||
for len(d.paste) > 0 {
|
||||
r, w := utf8.DecodeRune(d.paste)
|
||||
@@ -177,7 +140,7 @@ func (d *Reader) readEvents() ([]Event, error) {
|
||||
}
|
||||
d.paste = d.paste[w:]
|
||||
}
|
||||
d.paste = nil // reset the buffer
|
||||
d.paste = nil
|
||||
events = append(events, PasteEvent(paste))
|
||||
case nil:
|
||||
i++
|
||||
@@ -189,8 +152,41 @@ func (d *Reader) readEvents() ([]Event, error) {
|
||||
} else {
|
||||
events = append(events, ev)
|
||||
}
|
||||
i += nb
|
||||
i += consumed
|
||||
}
|
||||
|
||||
// Collapse bursts of wheel/motion events into a single event each.
|
||||
events = coalesceMouseEvents(events)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// coalesceMouseEvents reduces the volume of MouseWheelEvent and MouseMotionEvent
|
||||
// objects that arrive in rapid succession by keeping only the most recent
|
||||
// event in each contiguous run.
|
||||
func coalesceMouseEvents(in []Event) []Event {
|
||||
if len(in) < 2 {
|
||||
return in
|
||||
}
|
||||
|
||||
out := make([]Event, 0, len(in))
|
||||
for _, ev := range in {
|
||||
switch ev.(type) {
|
||||
case MouseWheelEvent:
|
||||
if len(out) > 0 {
|
||||
if _, ok := out[len(out)-1].(MouseWheelEvent); ok {
|
||||
out[len(out)-1] = ev // replace previous wheel event
|
||||
continue
|
||||
}
|
||||
}
|
||||
case MouseMotionEvent:
|
||||
if len(out) > 0 {
|
||||
if _, ok := out[len(out)-1].(MouseMotionEvent); ok {
|
||||
out[len(out)-1] = ev // replace previous motion event
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+47
-25
@@ -463,28 +463,41 @@ func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ u
|
||||
baseCode = KeyMediaStop
|
||||
case vkc == xwindows.VK_MEDIA_PLAY_PAUSE:
|
||||
baseCode = KeyMediaPlayPause
|
||||
case vkc == xwindows.VK_OEM_1:
|
||||
baseCode = ';'
|
||||
case vkc == xwindows.VK_OEM_PLUS:
|
||||
baseCode = '+'
|
||||
case vkc == xwindows.VK_OEM_COMMA:
|
||||
baseCode = ','
|
||||
case vkc == xwindows.VK_OEM_MINUS:
|
||||
baseCode = '-'
|
||||
case vkc == xwindows.VK_OEM_PERIOD:
|
||||
baseCode = '.'
|
||||
case vkc == xwindows.VK_OEM_2:
|
||||
baseCode = '/'
|
||||
case vkc == xwindows.VK_OEM_3:
|
||||
baseCode = '`'
|
||||
case vkc == xwindows.VK_OEM_4:
|
||||
baseCode = '['
|
||||
case vkc == xwindows.VK_OEM_5:
|
||||
baseCode = '\\'
|
||||
case vkc == xwindows.VK_OEM_6:
|
||||
baseCode = ']'
|
||||
case vkc == xwindows.VK_OEM_7:
|
||||
baseCode = '\''
|
||||
case vkc == xwindows.VK_OEM_1, vkc == xwindows.VK_OEM_PLUS, vkc == xwindows.VK_OEM_COMMA,
|
||||
vkc == xwindows.VK_OEM_MINUS, vkc == xwindows.VK_OEM_PERIOD, vkc == xwindows.VK_OEM_2,
|
||||
vkc == xwindows.VK_OEM_3, vkc == xwindows.VK_OEM_4, vkc == xwindows.VK_OEM_5,
|
||||
vkc == xwindows.VK_OEM_6, vkc == xwindows.VK_OEM_7:
|
||||
// Use the actual character provided by Windows for current keyboard layout
|
||||
// instead of hardcoded US layout mappings
|
||||
if !unicode.IsControl(r) && unicode.IsPrint(r) {
|
||||
baseCode = r
|
||||
} else {
|
||||
// Fallback to original hardcoded mappings for non-printable cases
|
||||
switch vkc {
|
||||
case xwindows.VK_OEM_1:
|
||||
baseCode = ';'
|
||||
case xwindows.VK_OEM_PLUS:
|
||||
baseCode = '+'
|
||||
case xwindows.VK_OEM_COMMA:
|
||||
baseCode = ','
|
||||
case xwindows.VK_OEM_MINUS:
|
||||
baseCode = '-'
|
||||
case xwindows.VK_OEM_PERIOD:
|
||||
baseCode = '.'
|
||||
case xwindows.VK_OEM_2:
|
||||
baseCode = '/'
|
||||
case xwindows.VK_OEM_3:
|
||||
baseCode = '`'
|
||||
case xwindows.VK_OEM_4:
|
||||
baseCode = '['
|
||||
case xwindows.VK_OEM_5:
|
||||
baseCode = '\\'
|
||||
case xwindows.VK_OEM_6:
|
||||
baseCode = ']'
|
||||
case xwindows.VK_OEM_7:
|
||||
baseCode = '\''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if utf16.IsSurrogate(r) {
|
||||
@@ -500,20 +513,29 @@ func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ u
|
||||
// XXX: Should this be a KeyMod?
|
||||
altGr := cks&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED) == xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED
|
||||
|
||||
// FIXED: Remove numlock and scroll lock states when checking for printable text
|
||||
// These lock states shouldn't affect normal typing
|
||||
cksForTextCheck := cks &^ (xwindows.NUMLOCK_ON | xwindows.SCROLLLOCK_ON)
|
||||
|
||||
var text string
|
||||
keyCode := baseCode
|
||||
if !unicode.IsControl(r) {
|
||||
rw := utf8.EncodeRune(utf8Buf[:], r)
|
||||
keyCode, _ = utf8.DecodeRune(utf8Buf[:rw])
|
||||
if unicode.IsPrint(keyCode) && (cks == 0 ||
|
||||
cks == xwindows.SHIFT_PRESSED ||
|
||||
cks == xwindows.CAPSLOCK_ON ||
|
||||
if unicode.IsPrint(keyCode) && (cksForTextCheck == 0 ||
|
||||
cksForTextCheck == xwindows.SHIFT_PRESSED ||
|
||||
cksForTextCheck == xwindows.CAPSLOCK_ON ||
|
||||
altGr) {
|
||||
// If the control key state is 0, shift is pressed, or caps lock
|
||||
// then the key event is a printable event i.e. [text] is not empty.
|
||||
text = string(keyCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: numeric keypad divide should produce "/" text on all layouts (fix french keyboard layout)
|
||||
if baseCode == KeyKpDivide {
|
||||
text = "/"
|
||||
}
|
||||
|
||||
key.Code = keyCode
|
||||
key.Text = text
|
||||
@@ -303,7 +303,8 @@ func (p *Parser) parseCsi(b []byte) (int, Event) {
|
||||
return i, CursorPositionEvent{Y: row - 1, X: col - 1}
|
||||
case 'm' | '<'<<parser.PrefixShift, 'M' | '<'<<parser.PrefixShift:
|
||||
// Handle SGR mouse
|
||||
if paramsLen == 3 {
|
||||
if paramsLen >= 3 {
|
||||
pa = pa[:3]
|
||||
return i, parseSGRMouseEvent(cmd, pa)
|
||||
}
|
||||
case 'm' | '>'<<parser.PrefixShift:
|
||||
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Path string `json:"path"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, program *tea.Program, client *opencode.Client) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
var req Request
|
||||
if err := client.Get(ctx, "/tui/control/next", nil, &req); err != nil {
|
||||
log.Printf("Error getting next request: %v", err)
|
||||
continue
|
||||
}
|
||||
program.Send(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Reply(ctx context.Context, client *opencode.Client, response interface{}) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := client.Post(ctx, "/tui/control/response", response, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
@@ -15,12 +14,17 @@ import (
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/id"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Info opencode.MessageUnion
|
||||
Parts []opencode.PartUnion
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Info opencode.App
|
||||
Modes []opencode.Mode
|
||||
@@ -29,13 +33,13 @@ type App struct {
|
||||
StatePath string
|
||||
Config *opencode.Config
|
||||
Client *opencode.Client
|
||||
State *config.State
|
||||
State *State
|
||||
ModeIndex int
|
||||
Mode *opencode.Mode
|
||||
Provider *opencode.Provider
|
||||
Model *opencode.Model
|
||||
Session *opencode.Session
|
||||
Messages []opencode.MessageUnion
|
||||
Messages []Message
|
||||
Commands commands.CommandRegistry
|
||||
InitialModel *string
|
||||
InitialPrompt *string
|
||||
@@ -44,7 +48,17 @@ type App struct {
|
||||
IsLeaderSequence bool
|
||||
}
|
||||
|
||||
type SessionCreatedMsg = struct {
|
||||
Session *opencode.Session
|
||||
}
|
||||
type SessionSelectedMsg = *opencode.Session
|
||||
type MessageRevertedMsg struct {
|
||||
Session opencode.Session
|
||||
Message Message
|
||||
}
|
||||
type SessionUnrevertedMsg struct {
|
||||
Session opencode.Session
|
||||
}
|
||||
type SessionLoadedMsg struct{}
|
||||
type ModelSelectedMsg struct {
|
||||
Provider opencode.Provider
|
||||
@@ -52,16 +66,10 @@ type ModelSelectedMsg struct {
|
||||
}
|
||||
type SessionClearedMsg struct{}
|
||||
type CompactSessionMsg struct{}
|
||||
type SendMsg struct {
|
||||
Text string
|
||||
Attachments []opencode.FilePartParam
|
||||
}
|
||||
type SendPrompt = Prompt
|
||||
type SetEditorContentMsg struct {
|
||||
Text string
|
||||
}
|
||||
type OptimisticMessageAddedMsg struct {
|
||||
Message opencode.MessageUnion
|
||||
}
|
||||
type FileRenderedMsg struct {
|
||||
FilePath string
|
||||
}
|
||||
@@ -89,20 +97,25 @@ func New(
|
||||
}
|
||||
|
||||
appStatePath := filepath.Join(appInfo.Path.State, "tui")
|
||||
appState, err := config.LoadState(appStatePath)
|
||||
appState, err := LoadState(appStatePath)
|
||||
if err != nil {
|
||||
appState = config.NewState()
|
||||
config.SaveState(appStatePath, appState)
|
||||
appState = NewState()
|
||||
SaveState(appStatePath, appState)
|
||||
}
|
||||
|
||||
if appState.ModeModel == nil {
|
||||
appState.ModeModel = make(map[string]config.ModeModel)
|
||||
appState.ModeModel = make(map[string]ModeModel)
|
||||
}
|
||||
|
||||
if configInfo.Theme != "" {
|
||||
appState.Theme = configInfo.Theme
|
||||
}
|
||||
|
||||
themeEnv := os.Getenv("OPENCODE_THEME")
|
||||
if themeEnv != "" {
|
||||
appState.Theme = themeEnv
|
||||
}
|
||||
|
||||
var modeIndex int
|
||||
var mode *opencode.Mode
|
||||
modeName := "build"
|
||||
@@ -121,7 +134,7 @@ func New(
|
||||
mode = &modes[modeIndex]
|
||||
|
||||
if mode.Model.ModelID != "" {
|
||||
appState.ModeModel[mode.Name] = config.ModeModel{
|
||||
appState.ModeModel[mode.Name] = ModeModel{
|
||||
ProviderID: mode.Model.ProviderID,
|
||||
ModelID: mode.Model.ModelID,
|
||||
}
|
||||
@@ -158,16 +171,31 @@ func New(
|
||||
ModeIndex: modeIndex,
|
||||
Mode: mode,
|
||||
Session: &opencode.Session{},
|
||||
Messages: []opencode.MessageUnion{},
|
||||
Messages: []Message{},
|
||||
Commands: commands.LoadFromConfig(configInfo),
|
||||
InitialModel: initialModel,
|
||||
InitialPrompt: initialPrompt,
|
||||
IntitialMode: initialMode,
|
||||
}
|
||||
|
||||
if app.Version != "dev" {
|
||||
delete(app.Commands, commands.MessagesUndoCommand)
|
||||
delete(app.Commands, commands.MessagesRedoCommand)
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *App) Keybind(commandName commands.CommandName) string {
|
||||
command := a.Commands[commandName]
|
||||
kb := command.Keybindings[0]
|
||||
key := kb.Key
|
||||
if kb.RequiresLeader {
|
||||
key = a.Config.Keybinds.Leader + " " + kb.Key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (a *App) Key(commandName commands.CommandName) string {
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
|
||||
@@ -177,15 +205,11 @@ func (a *App) Key(commandName commands.CommandName) string {
|
||||
Faint(true).
|
||||
Render
|
||||
command := a.Commands[commandName]
|
||||
kb := command.Keybindings[0]
|
||||
key := kb.Key
|
||||
if kb.RequiresLeader {
|
||||
key = a.Config.Keybinds.Leader + " " + kb.Key
|
||||
}
|
||||
key := a.Keybind(commandName)
|
||||
return base(key) + muted(" "+command.Description)
|
||||
}
|
||||
|
||||
func (a *App) SetClipboard(text string) tea.Cmd {
|
||||
func SetClipboard(text string) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
clipboard.Write(clipboard.FmtText, []byte(text))
|
||||
@@ -196,10 +220,17 @@ func (a *App) SetClipboard(text string) tea.Cmd {
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
func (a *App) SwitchMode() (*App, tea.Cmd) {
|
||||
a.ModeIndex++
|
||||
if a.ModeIndex >= len(a.Modes) {
|
||||
a.ModeIndex = 0
|
||||
func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
|
||||
if forward {
|
||||
a.ModeIndex++
|
||||
if a.ModeIndex >= len(a.Modes) {
|
||||
a.ModeIndex = 0
|
||||
}
|
||||
} else {
|
||||
a.ModeIndex--
|
||||
if a.ModeIndex < 0 {
|
||||
a.ModeIndex = len(a.Modes) - 1
|
||||
}
|
||||
}
|
||||
a.Mode = &a.Modes[a.ModeIndex]
|
||||
|
||||
@@ -228,15 +259,19 @@ func (a *App) SwitchMode() (*App, tea.Cmd) {
|
||||
}
|
||||
|
||||
a.State.Mode = a.Mode.Name
|
||||
return a, a.SaveState()
|
||||
}
|
||||
|
||||
return a, func() tea.Msg {
|
||||
a.SaveState()
|
||||
return nil
|
||||
}
|
||||
func (a *App) SwitchMode() (*App, tea.Cmd) {
|
||||
return a.cycleMode(true)
|
||||
}
|
||||
|
||||
func (a *App) SwitchModeReverse() (*App, tea.Cmd) {
|
||||
return a.cycleMode(false)
|
||||
}
|
||||
|
||||
func (a *App) InitializeProvider() tea.Cmd {
|
||||
providersResponse, err := a.Client.Config.Providers(context.Background())
|
||||
providersResponse, err := a.Client.App.Providers(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Failed to list providers", "error", err)
|
||||
// TODO: notify user
|
||||
@@ -325,13 +360,13 @@ func (a *App) InitializeProvider() tea.Cmd {
|
||||
Model: *currentModel,
|
||||
}))
|
||||
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
|
||||
cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
|
||||
cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
|
||||
}
|
||||
return tea.Sequence(cmds...)
|
||||
}
|
||||
|
||||
func getDefaultModel(
|
||||
response *opencode.ConfigProvidersResponse,
|
||||
response *opencode.AppProvidersResponse,
|
||||
provider opencode.Provider,
|
||||
) *opencode.Model {
|
||||
if match, ok := response.Default[provider.ID]; ok {
|
||||
@@ -349,18 +384,20 @@ func (a *App) IsBusy() bool {
|
||||
if len(a.Messages) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
lastMessage := a.Messages[len(a.Messages)-1]
|
||||
if casted, ok := lastMessage.(opencode.AssistantMessage); ok {
|
||||
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
|
||||
return casted.Time.Completed == 0
|
||||
}
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) SaveState() {
|
||||
err := config.SaveState(a.StatePath, a.State)
|
||||
if err != nil {
|
||||
slog.Error("Failed to save state", "error", err)
|
||||
func (a *App) SaveState() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := SaveState(a.StatePath, a.State)
|
||||
if err != nil {
|
||||
slog.Error("Failed to save state", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,10 +411,11 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
|
||||
}
|
||||
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
|
||||
|
||||
go func() {
|
||||
_, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
|
||||
MessageID: opencode.F(id.Ascending(id.Message)),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
})
|
||||
@@ -437,11 +475,7 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (a *App) SendChatMessage(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
attachments []opencode.FilePartParam,
|
||||
) (*App, tea.Cmd) {
|
||||
func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
if a.Session.ID == "" {
|
||||
session, err := a.CreateSession(ctx)
|
||||
@@ -449,60 +483,21 @@ func (a *App) SendChatMessage(
|
||||
return a, toast.NewErrorToast(err.Error())
|
||||
}
|
||||
a.Session = session
|
||||
cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
|
||||
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
|
||||
}
|
||||
|
||||
optimisticParts := []opencode.UserMessagePart{{
|
||||
Type: opencode.UserMessagePartTypeText,
|
||||
Text: text,
|
||||
}}
|
||||
if len(attachments) > 0 {
|
||||
for _, attachment := range attachments {
|
||||
optimisticParts = append(optimisticParts, opencode.UserMessagePart{
|
||||
Type: opencode.UserMessagePartTypeFile,
|
||||
Filename: attachment.Filename.Value,
|
||||
Mime: attachment.Mime.Value,
|
||||
URL: attachment.URL.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
messageID := id.Ascending(id.Message)
|
||||
message := prompt.ToMessage(messageID, a.Session.ID)
|
||||
|
||||
optimisticMessage := opencode.UserMessage{
|
||||
ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
|
||||
Role: opencode.UserMessageRoleUser,
|
||||
Parts: optimisticParts,
|
||||
SessionID: a.Session.ID,
|
||||
Time: opencode.UserMessageTime{
|
||||
Created: float64(time.Now().Unix()),
|
||||
},
|
||||
}
|
||||
|
||||
a.Messages = append(a.Messages, optimisticMessage)
|
||||
cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
|
||||
a.Messages = append(a.Messages, message)
|
||||
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
parts := []opencode.UserMessagePartUnionParam{
|
||||
opencode.TextPartParam{
|
||||
Type: opencode.F(opencode.TextPartTypeText),
|
||||
Text: opencode.F(text),
|
||||
},
|
||||
}
|
||||
if len(attachments) > 0 {
|
||||
for _, attachment := range attachments {
|
||||
parts = append(parts, opencode.FilePartParam{
|
||||
Mime: attachment.Mime,
|
||||
Type: attachment.Type,
|
||||
URL: attachment.URL,
|
||||
Filename: attachment.Filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
|
||||
Parts: opencode.F(parts),
|
||||
ProviderID: opencode.F(a.Provider.ID),
|
||||
ModelID: opencode.F(a.Model.ID),
|
||||
Mode: opencode.F(a.Mode.Name),
|
||||
MessageID: opencode.F(messageID),
|
||||
Parts: opencode.F(message.ToSessionChatParams()),
|
||||
})
|
||||
if err != nil {
|
||||
errormsg := fmt.Sprintf("failed to send message: %v", err)
|
||||
@@ -527,7 +522,6 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error {
|
||||
_, err := a.Client.Session.Abort(ctx, sessionID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to cancel session", "error", err)
|
||||
// status.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -542,9 +536,6 @@ func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
|
||||
return []opencode.Session{}, nil
|
||||
}
|
||||
sessions := *response
|
||||
sort.Slice(sessions, func(i, j int) bool {
|
||||
return sessions[i].Time.Created-sessions[j].Time.Created > 0
|
||||
})
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
@@ -557,20 +548,30 @@ func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
|
||||
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
|
||||
response, err := a.Client.Session.Messages(ctx, sessionId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response == nil {
|
||||
return []opencode.Message{}, nil
|
||||
return []Message{}, nil
|
||||
}
|
||||
messages := []Message{}
|
||||
for _, message := range *response {
|
||||
msg := Message{
|
||||
Info: message.Info.AsUnion(),
|
||||
Parts: []opencode.PartUnion{},
|
||||
}
|
||||
for _, part := range message.Parts {
|
||||
msg.Parts = append(msg.Parts, part.AsUnion())
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
messages := *response
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
|
||||
response, err := a.Client.Config.Providers(ctx)
|
||||
response, err := a.Client.App.Providers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/attachment"
|
||||
"github.com/sst/opencode/internal/id"
|
||||
)
|
||||
|
||||
type Prompt struct {
|
||||
Text string `toml:"text"`
|
||||
Attachments []*attachment.Attachment `toml:"attachments"`
|
||||
}
|
||||
|
||||
func (p Prompt) ToMessage(
|
||||
messageID string,
|
||||
sessionID string,
|
||||
) Message {
|
||||
message := opencode.UserMessage{
|
||||
ID: messageID,
|
||||
SessionID: sessionID,
|
||||
Role: opencode.UserMessageRoleUser,
|
||||
Time: opencode.UserMessageTime{
|
||||
Created: float64(time.Now().UnixMilli()),
|
||||
},
|
||||
}
|
||||
|
||||
text := p.Text
|
||||
textAttachments := []*attachment.Attachment{}
|
||||
for _, attachment := range p.Attachments {
|
||||
if attachment.Type == "text" {
|
||||
textAttachments = append(textAttachments, attachment)
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(textAttachments)-1; i++ {
|
||||
for j := i + 1; j < len(textAttachments); j++ {
|
||||
if textAttachments[i].StartIndex < textAttachments[j].StartIndex {
|
||||
textAttachments[i], textAttachments[j] = textAttachments[j], textAttachments[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, att := range textAttachments {
|
||||
if source, ok := att.GetTextSource(); ok {
|
||||
text = text[:att.StartIndex] + source.Value + text[att.EndIndex:]
|
||||
}
|
||||
}
|
||||
|
||||
parts := []opencode.PartUnion{opencode.TextPart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: messageID,
|
||||
SessionID: sessionID,
|
||||
Type: opencode.TextPartTypeText,
|
||||
Text: text,
|
||||
}}
|
||||
for _, attachment := range p.Attachments {
|
||||
text := opencode.FilePartSourceText{
|
||||
Start: int64(attachment.StartIndex),
|
||||
End: int64(attachment.EndIndex),
|
||||
Value: attachment.Display,
|
||||
}
|
||||
source := &opencode.FilePartSource{}
|
||||
switch attachment.Type {
|
||||
case "text":
|
||||
continue
|
||||
case "file":
|
||||
if fileSource, ok := attachment.GetFileSource(); ok {
|
||||
source = &opencode.FilePartSource{
|
||||
Text: text,
|
||||
Path: fileSource.Path,
|
||||
Type: opencode.FilePartSourceTypeFile,
|
||||
}
|
||||
}
|
||||
case "symbol":
|
||||
if symbolSource, ok := attachment.GetSymbolSource(); ok {
|
||||
source = &opencode.FilePartSource{
|
||||
Text: text,
|
||||
Path: symbolSource.Path,
|
||||
Type: opencode.FilePartSourceTypeSymbol,
|
||||
Kind: int64(symbolSource.Kind),
|
||||
Name: symbolSource.Name,
|
||||
Range: opencode.SymbolSourceRange{
|
||||
Start: opencode.SymbolSourceRangeStart{
|
||||
Line: float64(symbolSource.Range.Start.Line),
|
||||
Character: float64(symbolSource.Range.Start.Char),
|
||||
},
|
||||
End: opencode.SymbolSourceRangeEnd{
|
||||
Line: float64(symbolSource.Range.End.Line),
|
||||
Character: float64(symbolSource.Range.End.Char),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
parts = append(parts, opencode.FilePart{
|
||||
ID: id.Ascending(id.Part),
|
||||
MessageID: messageID,
|
||||
SessionID: sessionID,
|
||||
Type: opencode.FilePartTypeFile,
|
||||
Filename: attachment.Filename,
|
||||
Mime: attachment.MediaType,
|
||||
URL: attachment.URL,
|
||||
Source: *source,
|
||||
})
|
||||
}
|
||||
return Message{
|
||||
Info: message,
|
||||
Parts: parts,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Message) ToPrompt() (*Prompt, error) {
|
||||
switch m.Info.(type) {
|
||||
case opencode.UserMessage:
|
||||
text := ""
|
||||
attachments := []*attachment.Attachment{}
|
||||
for _, part := range m.Parts {
|
||||
switch p := part.(type) {
|
||||
case opencode.TextPart:
|
||||
if p.Synthetic {
|
||||
continue
|
||||
}
|
||||
text += p.Text + " "
|
||||
case opencode.FilePart:
|
||||
switch p.Source.Type {
|
||||
case "file":
|
||||
attachments = append(attachments, &attachment.Attachment{
|
||||
ID: p.ID,
|
||||
Type: "file",
|
||||
Display: p.Source.Text.Value,
|
||||
URL: p.URL,
|
||||
Filename: p.Filename,
|
||||
MediaType: p.Mime,
|
||||
StartIndex: int(p.Source.Text.Start),
|
||||
EndIndex: int(p.Source.Text.End),
|
||||
Source: &attachment.FileSource{
|
||||
Path: p.Source.Path,
|
||||
Mime: p.Mime,
|
||||
},
|
||||
})
|
||||
case "symbol":
|
||||
r := p.Source.Range.(opencode.SymbolSourceRange)
|
||||
attachments = append(attachments, &attachment.Attachment{
|
||||
ID: p.ID,
|
||||
Type: "symbol",
|
||||
Display: p.Source.Text.Value,
|
||||
URL: p.URL,
|
||||
Filename: p.Filename,
|
||||
MediaType: p.Mime,
|
||||
StartIndex: int(p.Source.Text.Start),
|
||||
EndIndex: int(p.Source.Text.End),
|
||||
Source: &attachment.SymbolSource{
|
||||
Path: p.Source.Path,
|
||||
Name: p.Source.Name,
|
||||
Kind: int(p.Source.Kind),
|
||||
Range: attachment.SymbolRange{
|
||||
Start: attachment.Position{
|
||||
Line: int(r.Start.Line),
|
||||
Char: int(r.Start.Character),
|
||||
},
|
||||
End: attachment.Position{
|
||||
Line: int(r.End.Line),
|
||||
Char: int(r.End.Character),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Prompt{
|
||||
Text: text,
|
||||
Attachments: attachments,
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("unknown message type")
|
||||
}
|
||||
|
||||
func (m Message) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
|
||||
parts := []opencode.SessionChatParamsPartUnion{}
|
||||
for _, part := range m.Parts {
|
||||
switch p := part.(type) {
|
||||
case opencode.TextPart:
|
||||
parts = append(parts, opencode.TextPartInputParam{
|
||||
ID: opencode.F(p.ID),
|
||||
Type: opencode.F(opencode.TextPartInputTypeText),
|
||||
Text: opencode.F(p.Text),
|
||||
Synthetic: opencode.F(p.Synthetic),
|
||||
Time: opencode.F(opencode.TextPartInputTimeParam{
|
||||
Start: opencode.F(p.Time.Start),
|
||||
End: opencode.F(p.Time.End),
|
||||
}),
|
||||
})
|
||||
case opencode.FilePart:
|
||||
var source opencode.FilePartSourceUnionParam
|
||||
switch p.Source.Type {
|
||||
case "file":
|
||||
source = opencode.FileSourceParam{
|
||||
Type: opencode.F(opencode.FileSourceTypeFile),
|
||||
Path: opencode.F(p.Source.Path),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Start: opencode.F(int64(p.Source.Text.Start)),
|
||||
End: opencode.F(int64(p.Source.Text.End)),
|
||||
Value: opencode.F(p.Source.Text.Value),
|
||||
}),
|
||||
}
|
||||
case "symbol":
|
||||
source = opencode.SymbolSourceParam{
|
||||
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
|
||||
Path: opencode.F(p.Source.Path),
|
||||
Name: opencode.F(p.Source.Name),
|
||||
Kind: opencode.F(p.Source.Kind),
|
||||
Range: opencode.F(opencode.SymbolSourceRangeParam{
|
||||
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
|
||||
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Line)),
|
||||
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Character)),
|
||||
}),
|
||||
End: opencode.F(opencode.SymbolSourceRangeEndParam{
|
||||
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Line)),
|
||||
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Character)),
|
||||
}),
|
||||
}),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Value: opencode.F(p.Source.Text.Value),
|
||||
Start: opencode.F(p.Source.Text.Start),
|
||||
End: opencode.F(p.Source.Text.End),
|
||||
}),
|
||||
}
|
||||
}
|
||||
parts = append(parts, opencode.FilePartInputParam{
|
||||
ID: opencode.F(p.ID),
|
||||
Type: opencode.F(opencode.FilePartInputTypeFile),
|
||||
Mime: opencode.F(p.Mime),
|
||||
URL: opencode.F(p.URL),
|
||||
Filename: opencode.F(p.Filename),
|
||||
Source: opencode.F(source),
|
||||
})
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func (p Prompt) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
|
||||
parts := []opencode.SessionChatParamsPartUnion{
|
||||
opencode.TextPartInputParam{
|
||||
Type: opencode.F(opencode.TextPartInputTypeText),
|
||||
Text: opencode.F(p.Text),
|
||||
},
|
||||
}
|
||||
for _, att := range p.Attachments {
|
||||
filePart := opencode.FilePartInputParam{
|
||||
Type: opencode.F(opencode.FilePartInputTypeFile),
|
||||
Mime: opencode.F(att.MediaType),
|
||||
URL: opencode.F(att.URL),
|
||||
Filename: opencode.F(att.Filename),
|
||||
}
|
||||
switch att.Type {
|
||||
case "file":
|
||||
if fs, ok := att.GetFileSource(); ok {
|
||||
filePart.Source = opencode.F(
|
||||
opencode.FilePartSourceUnionParam(opencode.FileSourceParam{
|
||||
Type: opencode.F(opencode.FileSourceTypeFile),
|
||||
Path: opencode.F(fs.Path),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Start: opencode.F(int64(att.StartIndex)),
|
||||
End: opencode.F(int64(att.EndIndex)),
|
||||
Value: opencode.F(att.Display),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
case "symbol":
|
||||
if ss, ok := att.GetSymbolSource(); ok {
|
||||
filePart.Source = opencode.F(
|
||||
opencode.FilePartSourceUnionParam(opencode.SymbolSourceParam{
|
||||
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
|
||||
Path: opencode.F(ss.Path),
|
||||
Name: opencode.F(ss.Name),
|
||||
Kind: opencode.F(int64(ss.Kind)),
|
||||
Range: opencode.F(opencode.SymbolSourceRangeParam{
|
||||
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
|
||||
Line: opencode.F(float64(ss.Range.Start.Line)),
|
||||
Character: opencode.F(float64(ss.Range.Start.Char)),
|
||||
}),
|
||||
End: opencode.F(opencode.SymbolSourceRangeEndParam{
|
||||
Line: opencode.F(float64(ss.Range.End.Line)),
|
||||
Character: opencode.F(float64(ss.Range.End.Char)),
|
||||
}),
|
||||
}),
|
||||
Text: opencode.F(opencode.FilePartSourceTextParam{
|
||||
Start: opencode.F(int64(att.StartIndex)),
|
||||
End: opencode.F(int64(att.EndIndex)),
|
||||
Value: opencode.F(att.Display),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
parts = append(parts, filePart)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package config
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -30,6 +30,7 @@ type State struct {
|
||||
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
|
||||
MessagesRight bool `toml:"messages_right"`
|
||||
SplitDiff bool `toml:"split_diff"`
|
||||
MessageHistory []Prompt `toml:"message_history"`
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
@@ -38,6 +39,7 @@ func NewState() *State {
|
||||
Mode: "build",
|
||||
ModeModel: make(map[string]ModeModel),
|
||||
RecentlyUsedModels: make([]ModelUsage, 0),
|
||||
MessageHistory: make([]Prompt, 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +71,22 @@ func (s *State) UpdateModelUsage(providerID, modelID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
|
||||
for i, usage := range s.RecentlyUsedModels {
|
||||
if usage.ProviderID == providerID && usage.ModelID == modelID {
|
||||
s.RecentlyUsedModels = append(s.RecentlyUsedModels[:i], s.RecentlyUsedModels[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) AddPromptToHistory(prompt Prompt) {
|
||||
s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
|
||||
if len(s.MessageHistory) > 50 {
|
||||
s.MessageHistory = s.MessageHistory[:50]
|
||||
}
|
||||
}
|
||||
|
||||
// SaveState writes the provided Config struct to the specified TOML file.
|
||||
// It will create the file if it doesn't exist, or overwrite it if it does.
|
||||
func SaveState(filePath string, state *State) error {
|
||||
@@ -0,0 +1,77 @@
|
||||
package attachment
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TextSource struct {
|
||||
Value string `toml:"value"`
|
||||
}
|
||||
|
||||
type FileSource struct {
|
||||
Path string `toml:"path"`
|
||||
Mime string `toml:"mime"`
|
||||
Data []byte `toml:"data,omitempty"` // Optional for image data
|
||||
}
|
||||
|
||||
type SymbolSource struct {
|
||||
Path string `toml:"path"`
|
||||
Name string `toml:"name"`
|
||||
Kind int `toml:"kind"`
|
||||
Range SymbolRange `toml:"range"`
|
||||
}
|
||||
|
||||
type SymbolRange struct {
|
||||
Start Position `toml:"start"`
|
||||
End Position `toml:"end"`
|
||||
}
|
||||
|
||||
type Position struct {
|
||||
Line int `toml:"line"`
|
||||
Char int `toml:"char"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID string `toml:"id"`
|
||||
Type string `toml:"type"`
|
||||
Display string `toml:"display"`
|
||||
URL string `toml:"url"`
|
||||
Filename string `toml:"filename"`
|
||||
MediaType string `toml:"media_type"`
|
||||
StartIndex int `toml:"start_index"`
|
||||
EndIndex int `toml:"end_index"`
|
||||
Source any `toml:"source,omitempty"`
|
||||
}
|
||||
|
||||
// NewAttachment creates a new attachment with a unique ID
|
||||
func NewAttachment() *Attachment {
|
||||
return &Attachment{
|
||||
ID: uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Attachment) GetTextSource() (*TextSource, bool) {
|
||||
if a.Type != "text" {
|
||||
return nil, false
|
||||
}
|
||||
ts, ok := a.Source.(*TextSource)
|
||||
return ts, ok
|
||||
}
|
||||
|
||||
// GetFileSource returns the source as FileSource if the attachment is a file type
|
||||
func (a *Attachment) GetFileSource() (*FileSource, bool) {
|
||||
if a.Type != "file" {
|
||||
return nil, false
|
||||
}
|
||||
fs, ok := a.Source.(*FileSource)
|
||||
return fs, ok
|
||||
}
|
||||
|
||||
// GetSymbolSource returns the source as SymbolSource if the attachment is a symbol type
|
||||
func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
|
||||
if a.Type != "symbol" {
|
||||
return nil, false
|
||||
}
|
||||
ss, ok := a.Source.(*SymbolSource)
|
||||
return ss, ok
|
||||
}
|
||||
+19
-9
@@ -13,6 +13,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -44,7 +45,7 @@ var (
|
||||
writeImg: []string{"xsel", "--clipboard", "--input"},
|
||||
},
|
||||
{
|
||||
name: "wl-clipboard",
|
||||
name: "wl-copy",
|
||||
readCmd: []string{"wl-paste", "-n"},
|
||||
writeCmd: []string{"wl-copy"},
|
||||
readImg: []string{"wl-paste", "-t", "image/png", "-n"},
|
||||
@@ -66,14 +67,23 @@ func initialize() error {
|
||||
return nil // Already initialized
|
||||
}
|
||||
|
||||
// Check which clipboard tool is available
|
||||
for i, tool := range clipboardTools {
|
||||
cmd := exec.Command("which", tool.name)
|
||||
if err := cmd.Run(); err == nil {
|
||||
clipboardTools[i].available = true
|
||||
if selectedTool < 0 {
|
||||
selectedTool = i
|
||||
slog.Debug("Clipboard tool found", "tool", tool.name)
|
||||
order := []string{"xclip", "xsel", "wl-copy"}
|
||||
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||
order = []string{"wl-copy", "xclip", "xsel"}
|
||||
}
|
||||
|
||||
for _, name := range order {
|
||||
for i, tool := range clipboardTools {
|
||||
if tool.name == name {
|
||||
cmd := exec.Command("which", tool.name)
|
||||
if err := cmd.Run(); err == nil {
|
||||
clipboardTools[i].available = true
|
||||
if selectedTool < 0 {
|
||||
selectedTool = i
|
||||
slog.Debug("Clipboard tool found", "tool", tool.name)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-12
@@ -63,17 +63,37 @@ func (r CommandRegistry) Sorted() []Command {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
slices.SortFunc(commands, func(a, b Command) int {
|
||||
// Priority order: session_new, session_share, model_list, app_help first, app_exit last
|
||||
priorityOrder := map[CommandName]int{
|
||||
SessionNewCommand: 0,
|
||||
AppHelpCommand: 1,
|
||||
SessionShareCommand: 2,
|
||||
ModelListCommand: 3,
|
||||
}
|
||||
|
||||
aPriority, aHasPriority := priorityOrder[a.Name]
|
||||
bPriority, bHasPriority := priorityOrder[b.Name]
|
||||
|
||||
if aHasPriority && bHasPriority {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
if aHasPriority {
|
||||
return -1
|
||||
}
|
||||
if bHasPriority {
|
||||
return 1
|
||||
}
|
||||
if a.Name == AppExitCommand {
|
||||
return 1
|
||||
}
|
||||
if b.Name == AppExitCommand {
|
||||
return -1
|
||||
}
|
||||
|
||||
return strings.Compare(string(a.Name), string(b.Name))
|
||||
})
|
||||
return commands
|
||||
}
|
||||
|
||||
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
|
||||
var matched []Command
|
||||
for _, command := range r.Sorted() {
|
||||
@@ -87,6 +107,7 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
|
||||
const (
|
||||
AppHelpCommand CommandName = "app_help"
|
||||
SwitchModeCommand CommandName = "switch_mode"
|
||||
SwitchModeReverseCommand CommandName = "switch_mode_reverse"
|
||||
EditorOpenCommand CommandName = "editor_open"
|
||||
SessionNewCommand CommandName = "session_new"
|
||||
SessionListCommand CommandName = "session_list"
|
||||
@@ -94,6 +115,7 @@ const (
|
||||
SessionUnshareCommand CommandName = "session_unshare"
|
||||
SessionInterruptCommand CommandName = "session_interrupt"
|
||||
SessionCompactCommand CommandName = "session_compact"
|
||||
SessionExportCommand CommandName = "session_export"
|
||||
ToolDetailsCommand CommandName = "tool_details"
|
||||
ModelListCommand CommandName = "model_list"
|
||||
ThemeListCommand CommandName = "theme_list"
|
||||
@@ -116,7 +138,8 @@ const (
|
||||
MessagesLastCommand CommandName = "messages_last"
|
||||
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
|
||||
MessagesCopyCommand CommandName = "messages_copy"
|
||||
MessagesRevertCommand CommandName = "messages_revert"
|
||||
MessagesUndoCommand CommandName = "messages_undo"
|
||||
MessagesRedoCommand CommandName = "messages_redo"
|
||||
AppExitCommand CommandName = "app_exit"
|
||||
)
|
||||
|
||||
@@ -155,15 +178,26 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
},
|
||||
{
|
||||
Name: SwitchModeCommand,
|
||||
Description: "switch mode",
|
||||
Description: "next mode",
|
||||
Keybindings: parseBindings("tab"),
|
||||
},
|
||||
{
|
||||
Name: SwitchModeReverseCommand,
|
||||
Description: "previous mode",
|
||||
Keybindings: parseBindings("shift+tab"),
|
||||
},
|
||||
{
|
||||
Name: EditorOpenCommand,
|
||||
Description: "open editor",
|
||||
Keybindings: parseBindings("<leader>e"),
|
||||
Trigger: []string{"editor"},
|
||||
},
|
||||
{
|
||||
Name: SessionExportCommand,
|
||||
Description: "export conversation",
|
||||
Keybindings: parseBindings("<leader>x"),
|
||||
Trigger: []string{"export"},
|
||||
},
|
||||
{
|
||||
Name: SessionNewCommand,
|
||||
Description: "new session",
|
||||
@@ -217,12 +251,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
Keybindings: parseBindings("<leader>t"),
|
||||
Trigger: []string{"themes"},
|
||||
},
|
||||
{
|
||||
Name: FileListCommand,
|
||||
Description: "list files",
|
||||
Keybindings: parseBindings("<leader>f"),
|
||||
Trigger: []string{"files"},
|
||||
},
|
||||
// {
|
||||
// Name: FileListCommand,
|
||||
// Description: "list files",
|
||||
// Keybindings: parseBindings("<leader>f"),
|
||||
// Trigger: []string{"files"},
|
||||
// },
|
||||
{
|
||||
Name: FileCloseCommand,
|
||||
Description: "close file",
|
||||
@@ -315,15 +349,22 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
Keybindings: parseBindings("<leader>y"),
|
||||
},
|
||||
{
|
||||
Name: MessagesRevertCommand,
|
||||
Description: "revert message",
|
||||
Name: MessagesUndoCommand,
|
||||
Description: "undo last message",
|
||||
Keybindings: parseBindings("<leader>u"),
|
||||
Trigger: []string{"undo"},
|
||||
},
|
||||
{
|
||||
Name: MessagesRedoCommand,
|
||||
Description: "redo message",
|
||||
Keybindings: parseBindings("<leader>r"),
|
||||
Trigger: []string{"redo"},
|
||||
},
|
||||
{
|
||||
Name: AppExitCommand,
|
||||
Description: "exit the app",
|
||||
Keybindings: parseBindings("ctrl+c", "<leader>q"),
|
||||
Trigger: []string{"exit", "quit"},
|
||||
Trigger: []string{"exit", "quit", "q"},
|
||||
},
|
||||
}
|
||||
registry := make(CommandRegistry)
|
||||
@@ -331,7 +372,15 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
||||
marshalled, _ := json.Marshal(config.Keybinds)
|
||||
json.Unmarshal(marshalled, &keybinds)
|
||||
for _, command := range defaults {
|
||||
// Remove share/unshare commands if sharing is disabled
|
||||
if config.Share == opencode.ConfigShareDisabled &&
|
||||
(command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
|
||||
continue
|
||||
}
|
||||
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
|
||||
if keybind == "none" {
|
||||
continue
|
||||
}
|
||||
command.Keybindings = parseBindings(keybind)
|
||||
}
|
||||
registry[command.Name] = command
|
||||
+29
-26
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
@@ -17,7 +16,7 @@ type CommandCompletionProvider struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func NewCommandCompletionProvider(app *app.App) dialog.CompletionProvider {
|
||||
func NewCommandCompletionProvider(app *app.App) CompletionProvider {
|
||||
return &CommandCompletionProvider{app: app}
|
||||
}
|
||||
|
||||
@@ -32,24 +31,28 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
|
||||
func (c *CommandCompletionProvider) getCommandCompletionItem(
|
||||
cmd commands.Command,
|
||||
space int,
|
||||
t theme.Theme,
|
||||
) dialog.CompletionItemI {
|
||||
spacer := strings.Repeat(" ", space)
|
||||
title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Render(spacer+cmd.Description)
|
||||
) CompletionSuggestion {
|
||||
displayFunc := func(s styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
spacer := strings.Repeat(" ", space)
|
||||
display := " /" + cmd.PrimaryTrigger() + s.
|
||||
Foreground(t.TextMuted()).
|
||||
Render(spacer+cmd.Description)
|
||||
return display
|
||||
}
|
||||
|
||||
value := string(cmd.Name)
|
||||
return dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
return CompletionSuggestion{
|
||||
Display: displayFunc,
|
||||
Value: value,
|
||||
ProviderID: c.GetId(),
|
||||
})
|
||||
RawData: cmd,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CommandCompletionProvider) GetChildEntries(
|
||||
query string,
|
||||
) ([]dialog.CompletionItemI, error) {
|
||||
t := theme.CurrentTheme()
|
||||
) ([]CompletionSuggestion, error) {
|
||||
commands := c.app.Commands
|
||||
|
||||
space := 1
|
||||
@@ -63,44 +66,44 @@ func (c *CommandCompletionProvider) GetChildEntries(
|
||||
sorted := commands.Sorted()
|
||||
if query == "" {
|
||||
// If no query, return all commands
|
||||
items := []dialog.CompletionItemI{}
|
||||
items := []CompletionSuggestion{}
|
||||
for _, cmd := range sorted {
|
||||
if !cmd.HasTrigger() {
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.PrimaryTrigger())
|
||||
items = append(items, c.getCommandCompletionItem(cmd, space, t))
|
||||
items = append(items, c.getCommandCompletionItem(cmd, space))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Use fuzzy matching for commands
|
||||
var commandNames []string
|
||||
commandMap := make(map[string]dialog.CompletionItemI)
|
||||
commandMap := make(map[string]CompletionSuggestion)
|
||||
|
||||
for _, cmd := range sorted {
|
||||
if !cmd.HasTrigger() {
|
||||
continue
|
||||
}
|
||||
space := space - lipgloss.Width(cmd.PrimaryTrigger())
|
||||
// Add all triggers as searchable options
|
||||
for _, trigger := range cmd.Trigger {
|
||||
commandNames = append(commandNames, trigger)
|
||||
commandMap[trigger] = c.getCommandCompletionItem(cmd, space, t)
|
||||
commandMap[trigger] = c.getCommandCompletionItem(cmd, space)
|
||||
}
|
||||
}
|
||||
|
||||
// Find fuzzy matches
|
||||
matches := fuzzy.RankFind(query, commandNames)
|
||||
|
||||
// Sort by score (best matches first)
|
||||
matches := fuzzy.RankFindFold(query, commandNames)
|
||||
sort.Sort(matches)
|
||||
|
||||
// Convert matches to completion items
|
||||
items := []dialog.CompletionItemI{}
|
||||
// Convert matches to completion items, deduplicating by command name
|
||||
items := []CompletionSuggestion{}
|
||||
seen := make(map[string]bool)
|
||||
for _, match := range matches {
|
||||
if item, ok := commandMap[match.Target]; ok {
|
||||
items = append(items, item)
|
||||
// Use the command's value (name) as the deduplication key
|
||||
if !seen[item.Value] {
|
||||
seen[item.Value] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
+33
-26
@@ -9,14 +9,13 @@ import (
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
type filesContextGroup struct {
|
||||
app *app.App
|
||||
gitFiles []dialog.CompletionItemI
|
||||
gitFiles []CompletionSuggestion
|
||||
}
|
||||
|
||||
func (cg *filesContextGroup) GetId() string {
|
||||
@@ -27,12 +26,8 @@ func (cg *filesContextGroup) GetEmptyMessage() string {
|
||||
return "no matching files"
|
||||
}
|
||||
|
||||
func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||
t := theme.CurrentTheme()
|
||||
items := make([]dialog.CompletionItemI, 0)
|
||||
base := styles.NewStyle().Background(t.BackgroundElement())
|
||||
green := base.Foreground(t.Success()).Render
|
||||
red := base.Foreground(t.Error()).Render
|
||||
func (cg *filesContextGroup) getGitFiles() []CompletionSuggestion {
|
||||
items := make([]CompletionSuggestion, 0)
|
||||
|
||||
status, _ := cg.app.Client.File.Status(context.Background())
|
||||
if status != nil {
|
||||
@@ -42,19 +37,25 @@ func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||
})
|
||||
|
||||
for _, file := range files {
|
||||
title := file.Path
|
||||
if file.Added > 0 {
|
||||
title += green(" +" + strconv.Itoa(int(file.Added)))
|
||||
displayFunc := func(s styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
green := s.Foreground(t.Success()).Render
|
||||
red := s.Foreground(t.Error()).Render
|
||||
display := file.Path
|
||||
if file.Added > 0 {
|
||||
display += green(" +" + strconv.Itoa(int(file.Added)))
|
||||
}
|
||||
if file.Removed > 0 {
|
||||
display += red(" -" + strconv.Itoa(int(file.Removed)))
|
||||
}
|
||||
return display
|
||||
}
|
||||
if file.Removed > 0 {
|
||||
title += red(" -" + strconv.Itoa(int(file.Removed)))
|
||||
}
|
||||
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
item := CompletionSuggestion{
|
||||
Display: displayFunc,
|
||||
Value: file.Path,
|
||||
ProviderID: cg.GetId(),
|
||||
Raw: file,
|
||||
})
|
||||
RawData: file,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
@@ -64,8 +65,8 @@ func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
|
||||
|
||||
func (cg *filesContextGroup) GetChildEntries(
|
||||
query string,
|
||||
) ([]dialog.CompletionItemI, error) {
|
||||
items := make([]dialog.CompletionItemI, 0)
|
||||
) ([]CompletionSuggestion, error) {
|
||||
items := make([]CompletionSuggestion, 0)
|
||||
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
@@ -87,7 +88,7 @@ func (cg *filesContextGroup) GetChildEntries(
|
||||
for _, file := range *files {
|
||||
exists := false
|
||||
for _, existing := range cg.gitFiles {
|
||||
if existing.GetValue() == file {
|
||||
if existing.Value == file {
|
||||
if query != "" {
|
||||
items = append(items, existing)
|
||||
}
|
||||
@@ -95,12 +96,18 @@ func (cg *filesContextGroup) GetChildEntries(
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: file,
|
||||
displayFunc := func(s styles.Style) string {
|
||||
// t := theme.CurrentTheme()
|
||||
// return s.Foreground(t.Text()).Render(file)
|
||||
return s.Render(file)
|
||||
}
|
||||
|
||||
item := CompletionSuggestion{
|
||||
Display: displayFunc,
|
||||
Value: file,
|
||||
ProviderID: cg.GetId(),
|
||||
Raw: file,
|
||||
})
|
||||
RawData: file,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +115,7 @@ func (cg *filesContextGroup) GetChildEntries(
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func NewFileContextGroup(app *app.App) dialog.CompletionProvider {
|
||||
func NewFileContextGroup(app *app.App) CompletionProvider {
|
||||
cg := &filesContextGroup{
|
||||
app: app,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package completions
|
||||
|
||||
// CompletionProvider defines the interface for completion data providers
|
||||
type CompletionProvider interface {
|
||||
GetId() string
|
||||
GetChildEntries(query string) ([]CompletionSuggestion, error)
|
||||
GetEmptyMessage() string
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package completions
|
||||
|
||||
import "github.com/sst/opencode/internal/styles"
|
||||
|
||||
// CompletionSuggestion represents a data-only completion suggestion
|
||||
// with no styling or rendering logic
|
||||
type CompletionSuggestion struct {
|
||||
// The text to be displayed in the list. May contain minimal inline
|
||||
// ANSI styling if intrinsic to the data (e.g., git diff colors).
|
||||
Display func(styles.Style) string
|
||||
|
||||
// The value to be used when the item is selected (e.g., inserted into the editor).
|
||||
Value string
|
||||
|
||||
// An optional, longer description to be displayed.
|
||||
Description string
|
||||
|
||||
// The ID of the provider that generated this suggestion.
|
||||
ProviderID string
|
||||
|
||||
// The raw, underlying data object (e.g., opencode.Symbol, commands.Command).
|
||||
// This allows the selection handler to perform rich actions.
|
||||
RawData any
|
||||
}
|
||||
+22
-21
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
@@ -58,8 +57,8 @@ const (
|
||||
|
||||
func (cg *symbolsContextGroup) GetChildEntries(
|
||||
query string,
|
||||
) ([]dialog.CompletionItemI, error) {
|
||||
items := make([]dialog.CompletionItemI, 0)
|
||||
) ([]CompletionSuggestion, error) {
|
||||
items := make([]CompletionSuggestion, 0)
|
||||
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
@@ -78,40 +77,42 @@ func (cg *symbolsContextGroup) GetChildEntries(
|
||||
return items, nil
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
|
||||
base := baseStyle.Render
|
||||
muted := baseStyle.Foreground(t.TextMuted()).Render
|
||||
|
||||
for _, sym := range *symbols {
|
||||
parts := strings.Split(sym.Name, ".")
|
||||
lastPart := parts[len(parts)-1]
|
||||
title := base(lastPart)
|
||||
|
||||
uriParts := strings.Split(sym.Location.Uri, "/")
|
||||
lastTwoParts := uriParts[len(uriParts)-2:]
|
||||
joined := strings.Join(lastTwoParts, "/")
|
||||
title += muted(fmt.Sprintf(" %s", joined))
|
||||
|
||||
start := int(sym.Location.Range.Start.Line)
|
||||
end := int(sym.Location.Range.End.Line)
|
||||
title += muted(fmt.Sprintf(":L%d-%d", start, end))
|
||||
|
||||
displayFunc := func(s styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
base := s.Foreground(t.Text()).Render
|
||||
muted := s.Foreground(t.TextMuted()).Render
|
||||
display := base(lastPart)
|
||||
|
||||
uriParts := strings.Split(sym.Location.Uri, "/")
|
||||
lastTwoParts := uriParts[len(uriParts)-2:]
|
||||
joined := strings.Join(lastTwoParts, "/")
|
||||
display += muted(fmt.Sprintf(" %s", joined))
|
||||
|
||||
display += muted(fmt.Sprintf(":L%d-%d", start, end))
|
||||
return display
|
||||
}
|
||||
|
||||
value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
|
||||
|
||||
item := dialog.NewCompletionItem(dialog.CompletionItem{
|
||||
Title: title,
|
||||
item := CompletionSuggestion{
|
||||
Display: displayFunc,
|
||||
Value: value,
|
||||
ProviderID: cg.GetId(),
|
||||
Raw: sym,
|
||||
})
|
||||
RawData: sym,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func NewSymbolsContextGroup(app *app.App) dialog.CompletionProvider {
|
||||
func NewSymbolsContextGroup(app *app.App) CompletionProvider {
|
||||
return &symbolsContextGroup{
|
||||
app: app,
|
||||
}
|
||||
+12
-12
@@ -1,28 +1,28 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MessageCache caches rendered messages to avoid re-rendering
|
||||
type MessageCache struct {
|
||||
// PartCache caches rendered messages to avoid re-rendering
|
||||
type PartCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]string
|
||||
}
|
||||
|
||||
// NewMessageCache creates a new message cache
|
||||
func NewMessageCache() *MessageCache {
|
||||
return &MessageCache{
|
||||
// NewPartCache creates a new message cache
|
||||
func NewPartCache() *PartCache {
|
||||
return &PartCache{
|
||||
cache: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// generateKey creates a unique key for a message based on its content and rendering parameters
|
||||
func (c *MessageCache) GenerateKey(params ...any) string {
|
||||
h := sha256.New()
|
||||
func (c *PartCache) GenerateKey(params ...any) string {
|
||||
h := fnv.New64a()
|
||||
for _, param := range params {
|
||||
h.Write(fmt.Appendf(nil, ":%v", param))
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (c *MessageCache) GenerateKey(params ...any) string {
|
||||
}
|
||||
|
||||
// Get retrieves a cached rendered message
|
||||
func (c *MessageCache) Get(key string) (string, bool) {
|
||||
func (c *PartCache) Get(key string) (string, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@@ -39,14 +39,14 @@ func (c *MessageCache) Get(key string) (string, bool) {
|
||||
}
|
||||
|
||||
// Set stores a rendered message in the cache
|
||||
func (c *MessageCache) Set(key string, content string) {
|
||||
func (c *PartCache) Set(key string, content string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cache[key] = content
|
||||
}
|
||||
|
||||
// Clear removes all entries from the cache
|
||||
func (c *MessageCache) Clear() {
|
||||
func (c *PartCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -54,7 +54,7 @@ func (c *MessageCache) Clear() {
|
||||
}
|
||||
|
||||
// Size returns the number of cached entries
|
||||
func (c *MessageCache) Size() int {
|
||||
func (c *PartCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/attachment"
|
||||
"github.com/sst/opencode/internal/clipboard"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/textarea"
|
||||
"github.com/sst/opencode/internal/components/toast"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type EditorComponent interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
Content() string
|
||||
Lines() int
|
||||
Value() string
|
||||
Length() int
|
||||
Focused() bool
|
||||
Focus() (tea.Model, tea.Cmd)
|
||||
Blur()
|
||||
Submit() (tea.Model, tea.Cmd)
|
||||
Clear() (tea.Model, tea.Cmd)
|
||||
Paste() (tea.Model, tea.Cmd)
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
SetValue(value string)
|
||||
SetValueWithAttachments(value string)
|
||||
SetInterruptKeyInDebounce(inDebounce bool)
|
||||
SetExitKeyInDebounce(inDebounce bool)
|
||||
RestoreFromHistory(index int)
|
||||
}
|
||||
|
||||
type editorComponent struct {
|
||||
app *app.App
|
||||
width int
|
||||
textarea textarea.Model
|
||||
spinner spinner.Model
|
||||
interruptKeyInDebounce bool
|
||||
exitKeyInDebounce bool
|
||||
historyIndex int // -1 means current (not in history)
|
||||
currentText string // Store current text when navigating history
|
||||
pasteCounter int
|
||||
reverted bool
|
||||
}
|
||||
|
||||
func (m *editorComponent) Init() tea.Cmd {
|
||||
return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width - 4
|
||||
return m, nil
|
||||
case spinner.TickMsg:
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
case tea.KeyPressMsg:
|
||||
// Handle up/down arrows and ctrl+p/ctrl+n for history navigation
|
||||
switch msg.String() {
|
||||
case "up", "ctrl+p":
|
||||
// Only navigate history if cursor is at the first line and column (for arrow keys)
|
||||
// or allow ctrl+p from anywhere
|
||||
if (msg.String() == "ctrl+p" || (m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0)) && len(m.app.State.MessageHistory) > 0 {
|
||||
if m.historyIndex == -1 {
|
||||
// Save current text before entering history
|
||||
m.currentText = m.textarea.Value()
|
||||
m.textarea.MoveToBegin()
|
||||
}
|
||||
// Move up in history (older messages)
|
||||
if m.historyIndex < len(m.app.State.MessageHistory)-1 {
|
||||
m.historyIndex++
|
||||
m.RestoreFromHistory(m.historyIndex)
|
||||
m.textarea.MoveToBegin()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case "down", "ctrl+n":
|
||||
// Only navigate history if cursor is at the last line and we're in history navigation (for arrow keys)
|
||||
// or allow ctrl+n from anywhere if we're in history navigation
|
||||
if (msg.String() == "ctrl+n" || m.textarea.IsCursorAtEnd()) && m.historyIndex > -1 {
|
||||
// Move down in history (newer messages)
|
||||
m.historyIndex--
|
||||
if m.historyIndex == -1 {
|
||||
// Restore current text
|
||||
m.textarea.Reset()
|
||||
m.textarea.SetValue(m.currentText)
|
||||
m.currentText = ""
|
||||
} else {
|
||||
m.RestoreFromHistory(m.historyIndex)
|
||||
m.textarea.MoveToEnd()
|
||||
}
|
||||
return m, nil
|
||||
} else if m.historyIndex > -1 && msg.String() == "down" {
|
||||
m.textarea.MoveToEnd()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
// Reset history navigation on any other input
|
||||
if m.historyIndex != -1 {
|
||||
m.historyIndex = -1
|
||||
m.currentText = ""
|
||||
}
|
||||
// Maximize editor responsiveness for printable characters
|
||||
if msg.Text != "" {
|
||||
m.reverted = false
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
case app.MessageRevertedMsg:
|
||||
if msg.Session.ID == m.app.Session.ID {
|
||||
switch msg.Message.Info.(type) {
|
||||
case opencode.UserMessage:
|
||||
prompt, err := msg.Message.ToPrompt()
|
||||
if err != nil {
|
||||
return m, toast.NewErrorToast("Failed to revert message")
|
||||
}
|
||||
m.RestoreFromPrompt(*prompt)
|
||||
m.textarea.MoveToEnd()
|
||||
m.reverted = true
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
case app.SessionUnrevertedMsg:
|
||||
if msg.Session.ID == m.app.Session.ID {
|
||||
if m.reverted {
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
case tea.PasteMsg:
|
||||
text := string(msg)
|
||||
|
||||
if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
|
||||
statPath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
statPath = filepath.Join(m.app.Info.Path.Cwd, filePath)
|
||||
}
|
||||
if _, err := os.Stat(statPath); err == nil {
|
||||
attachment := m.createAttachmentFromPath(filePath)
|
||||
if attachment != nil {
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text = strings.ReplaceAll(text, "\\", "")
|
||||
text, err := strconv.Unquote(`"` + text + `"`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unquote text", "error", err)
|
||||
text := string(msg)
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if _, err := os.Stat(text); err != nil {
|
||||
slog.Error("Failed to paste file", "error", err)
|
||||
text := string(msg)
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
filePath := text
|
||||
|
||||
attachment := m.createAttachmentFromFile(filePath)
|
||||
if attachment == nil {
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(msg))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
case tea.ClipboardMsg:
|
||||
text := string(msg)
|
||||
// Check if the pasted text is long and should be summarized
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(text))
|
||||
}
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.textarea = updateTextareaStyles(m.textarea)
|
||||
m.spinner = createSpinner()
|
||||
return m, tea.Batch(m.textarea.Focus(), m.spinner.Tick)
|
||||
case dialog.CompletionSelectedMsg:
|
||||
switch msg.Item.ProviderID {
|
||||
case "commands":
|
||||
commandName := strings.TrimPrefix(msg.Item.Value, "/")
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
|
||||
return m, tea.Batch(cmds...)
|
||||
case "files":
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.Item.Value + " ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// The range to replace is from the '@' up to the current cursor position.
|
||||
// Replace the search term (e.g., "@search") with an empty string first.
|
||||
cursorCol := m.textarea.CursorColumn()
|
||||
m.textarea.ReplaceRange(atIndex, cursorCol, "")
|
||||
|
||||
// Now, insert the attachment at the position where the '@' was.
|
||||
// The cursor is now at `atIndex` after the replacement.
|
||||
filePath := msg.Item.Value
|
||||
attachment := m.createAttachmentFromPath(filePath)
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
case "symbols":
|
||||
atIndex := m.textarea.LastRuneIndex('@')
|
||||
if atIndex == -1 {
|
||||
// Should not happen, but as a fallback, just insert.
|
||||
m.textarea.InsertString(msg.Item.Value + " ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
cursorCol := m.textarea.CursorColumn()
|
||||
m.textarea.ReplaceRange(atIndex, cursorCol, "")
|
||||
|
||||
symbol := msg.Item.RawData.(opencode.Symbol)
|
||||
parts := strings.Split(symbol.Name, ".")
|
||||
lastPart := parts[len(parts)-1]
|
||||
attachment := &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "symbol",
|
||||
Display: "@" + lastPart,
|
||||
URL: msg.Item.Value,
|
||||
Filename: lastPart,
|
||||
MediaType: "text/plain",
|
||||
Source: &attachment.SymbolSource{
|
||||
Path: symbol.Location.Uri,
|
||||
Name: symbol.Name,
|
||||
Kind: int(symbol.Kind),
|
||||
Range: attachment.SymbolRange{
|
||||
Start: attachment.Position{
|
||||
Line: int(symbol.Location.Range.Start.Line),
|
||||
Char: int(symbol.Location.Range.Start.Character),
|
||||
},
|
||||
End: attachment.Position{
|
||||
Line: int(symbol.Location.Range.End.Line),
|
||||
Char: int(symbol.Location.Range.End.Character),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
default:
|
||||
slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Content() string {
|
||||
width := m.width
|
||||
if m.app.Session.ID == "" {
|
||||
width = min(width, 80)
|
||||
}
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||
promptStyle := styles.NewStyle().Foreground(t.Primary()).
|
||||
Padding(0, 0, 0, 1).
|
||||
Bold(true)
|
||||
prompt := promptStyle.Render(">")
|
||||
|
||||
m.textarea.SetWidth(width - 6)
|
||||
textarea := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
prompt,
|
||||
m.textarea.View(),
|
||||
)
|
||||
borderForeground := t.Border()
|
||||
if m.app.IsLeaderSequence {
|
||||
borderForeground = t.Accent()
|
||||
}
|
||||
textarea = styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingTop(1).
|
||||
PaddingBottom(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderForeground(borderForeground).
|
||||
BorderBackground(t.Background()).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
Render(textarea)
|
||||
|
||||
hint := base(m.getSubmitKeyText()) + muted(" send ")
|
||||
if m.exitKeyInDebounce {
|
||||
keyText := m.getExitKeyText()
|
||||
hint = base(keyText+" again") + muted(" to exit")
|
||||
} else if m.app.IsBusy() {
|
||||
keyText := m.getInterruptKeyText()
|
||||
if m.interruptKeyInDebounce {
|
||||
hint = muted(
|
||||
"working",
|
||||
) + m.spinner.View() + muted(
|
||||
" ",
|
||||
) + base(
|
||||
keyText+" again",
|
||||
) + muted(
|
||||
" interrupt",
|
||||
)
|
||||
} else {
|
||||
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
|
||||
}
|
||||
}
|
||||
|
||||
model := ""
|
||||
if m.app.Model != nil {
|
||||
model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
|
||||
}
|
||||
|
||||
space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
|
||||
spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
|
||||
|
||||
info := hint + spacer + model
|
||||
info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
|
||||
|
||||
content := strings.Join([]string{"", textarea, info}, "\n")
|
||||
return content
|
||||
}
|
||||
|
||||
func (m *editorComponent) View() string {
|
||||
width := m.width
|
||||
if m.app.Session.ID == "" {
|
||||
width = min(width, 80)
|
||||
}
|
||||
|
||||
if m.Lines() > 1 {
|
||||
return lipgloss.Place(
|
||||
width,
|
||||
5,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
"",
|
||||
styles.WhitespaceStyle(theme.CurrentTheme().Background()),
|
||||
)
|
||||
}
|
||||
return m.Content()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Focused() bool {
|
||||
return m.textarea.Focused()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
|
||||
return m, m.textarea.Focus()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Blur() {
|
||||
m.textarea.Blur()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Lines() int {
|
||||
return m.textarea.LineCount()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Value() string {
|
||||
return m.textarea.Value()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Length() int {
|
||||
return m.textarea.Length()
|
||||
}
|
||||
|
||||
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||
value := strings.TrimSpace(m.Value())
|
||||
if value == "" {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch value {
|
||||
case "exit", "quit", "q", ":q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
if len(value) > 0 && value[len(value)-1] == '\\' {
|
||||
// If the last character is a backslash, remove it and add a newline
|
||||
backslashCol := m.textarea.CurrentRowLength() - 1
|
||||
m.textarea.ReplaceRange(backslashCol, backslashCol+1, "")
|
||||
m.textarea.InsertString("\n")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
attachments := m.textarea.GetAttachments()
|
||||
|
||||
prompt := app.Prompt{Text: value, Attachments: attachments}
|
||||
m.app.State.AddPromptToHistory(prompt)
|
||||
cmds = append(cmds, m.app.SaveState())
|
||||
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Reset()
|
||||
m.historyIndex = -1
|
||||
m.currentText = ""
|
||||
m.pasteCounter = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
|
||||
imageBytes := clipboard.Read(clipboard.FmtImage)
|
||||
if imageBytes != nil {
|
||||
attachmentCount := len(m.textarea.GetAttachments())
|
||||
attachmentIndex := attachmentCount + 1
|
||||
base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
attachment := &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
MediaType: "image/png",
|
||||
Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
|
||||
Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
|
||||
URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
|
||||
Source: &attachment.FileSource{
|
||||
Path: fmt.Sprintf("image-%d.png", attachmentIndex),
|
||||
Mime: "image/png",
|
||||
Data: imageBytes,
|
||||
},
|
||||
}
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
textBytes := clipboard.Read(clipboard.FmtText)
|
||||
if textBytes != nil {
|
||||
text := string(textBytes)
|
||||
// Check if the pasted text is long and should be summarized
|
||||
if m.shouldSummarizePastedText(text) {
|
||||
m.handleLongPaste(text)
|
||||
} else {
|
||||
m.textarea.InsertRunesFromUserInput([]rune(text))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// fallback to reading the clipboard using OSC52
|
||||
return m, tea.ReadClipboard
|
||||
}
|
||||
|
||||
func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Newline()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
||||
m.interruptKeyInDebounce = inDebounce
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetValue(value string) {
|
||||
m.textarea.SetValue(value)
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetValueWithAttachments(value string) {
|
||||
m.textarea.Reset()
|
||||
|
||||
i := 0
|
||||
for i < len(value) {
|
||||
// Check if filepath and add attachment
|
||||
if value[i] == '@' {
|
||||
start := i + 1
|
||||
end := start
|
||||
for end < len(value) && value[end] != ' ' && value[end] != '\t' && value[end] != '\n' && value[end] != '\r' {
|
||||
end++
|
||||
}
|
||||
|
||||
if end > start {
|
||||
filePath := value[start:end]
|
||||
slog.Debug("test", "filePath", filePath)
|
||||
if _, err := os.Stat(filepath.Join(m.app.Info.Path.Cwd, filePath)); err == nil {
|
||||
slog.Debug("test", "found", true)
|
||||
attachment := m.createAttachmentFromFile(filePath)
|
||||
if attachment != nil {
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a valid file path, insert the character normally
|
||||
m.textarea.InsertRune(rune(value[i]))
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
|
||||
m.exitKeyInDebounce = inDebounce
|
||||
}
|
||||
|
||||
func (m *editorComponent) getInterruptKeyText() string {
|
||||
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
|
||||
}
|
||||
|
||||
func (m *editorComponent) getSubmitKeyText() string {
|
||||
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
|
||||
}
|
||||
|
||||
func (m *editorComponent) getExitKeyText() string {
|
||||
return m.app.Commands[commands.AppExitCommand].Keys()[0]
|
||||
}
|
||||
|
||||
// shouldSummarizePastedText determines if pasted text should be summarized
|
||||
func (m *editorComponent) shouldSummarizePastedText(text string) bool {
|
||||
lines := strings.Split(text, "\n")
|
||||
lineCount := len(lines)
|
||||
charCount := len(text)
|
||||
|
||||
// Consider text long if it has more than 3 lines or more than 150 characters
|
||||
return lineCount > 3 || charCount > 150
|
||||
}
|
||||
|
||||
// handleLongPaste handles long pasted text by creating a summary attachment
|
||||
func (m *editorComponent) handleLongPaste(text string) {
|
||||
lines := strings.Split(text, "\n")
|
||||
lineCount := len(lines)
|
||||
|
||||
// Increment paste counter
|
||||
m.pasteCounter++
|
||||
|
||||
// Create attachment with full text as base64 encoded data
|
||||
fileBytes := []byte(text)
|
||||
base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
|
||||
url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
|
||||
|
||||
fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
|
||||
displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
|
||||
|
||||
attachment := &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "text",
|
||||
MediaType: "text/plain",
|
||||
Display: displayText,
|
||||
URL: url,
|
||||
Filename: fileName,
|
||||
Source: &attachment.TextSource{
|
||||
Value: text,
|
||||
},
|
||||
}
|
||||
|
||||
m.textarea.InsertAttachment(attachment)
|
||||
m.textarea.InsertString(" ")
|
||||
}
|
||||
|
||||
func updateTextareaStyles(ta textarea.Model) textarea.Model {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||
ta.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
|
||||
ta.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
|
||||
ta.Styles.Attachment = styles.NewStyle().
|
||||
Foreground(t.Secondary()).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ta.Styles.SelectedAttachment = styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.Secondary()).
|
||||
Lipgloss()
|
||||
ta.Styles.Cursor.Color = t.Primary()
|
||||
return ta
|
||||
}
|
||||
|
||||
func createSpinner() spinner.Model {
|
||||
t := theme.CurrentTheme()
|
||||
return spinner.New(
|
||||
spinner.WithSpinner(spinner.Ellipsis),
|
||||
spinner.WithStyle(
|
||||
styles.NewStyle().
|
||||
Background(t.Background()).
|
||||
Foreground(t.TextMuted()).
|
||||
Width(3).
|
||||
Lipgloss(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func NewEditorComponent(app *app.App) EditorComponent {
|
||||
s := createSpinner()
|
||||
|
||||
ta := textarea.New()
|
||||
ta.Prompt = " "
|
||||
ta.ShowLineNumbers = false
|
||||
ta.CharLimit = -1
|
||||
ta = updateTextareaStyles(ta)
|
||||
|
||||
m := &editorComponent{
|
||||
app: app,
|
||||
textarea: ta,
|
||||
spinner: s,
|
||||
interruptKeyInDebounce: false,
|
||||
historyIndex: -1,
|
||||
pasteCounter: 0,
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *editorComponent) RestoreFromPrompt(prompt app.Prompt) {
|
||||
m.textarea.Reset()
|
||||
m.textarea.SetValue(prompt.Text)
|
||||
|
||||
// Sort attachments by start index in reverse order (process from end to beginning)
|
||||
// This prevents index shifting issues
|
||||
attachmentsCopy := make([]*attachment.Attachment, len(prompt.Attachments))
|
||||
copy(attachmentsCopy, prompt.Attachments)
|
||||
|
||||
for i := 0; i < len(attachmentsCopy)-1; i++ {
|
||||
for j := i + 1; j < len(attachmentsCopy); j++ {
|
||||
if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
|
||||
attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, att := range attachmentsCopy {
|
||||
m.textarea.SetCursorColumn(att.StartIndex)
|
||||
m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
|
||||
m.textarea.InsertAttachment(att)
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreFromHistory restores a message from history at the given index
|
||||
func (m *editorComponent) RestoreFromHistory(index int) {
|
||||
if index < 0 || index >= len(m.app.State.MessageHistory) {
|
||||
return
|
||||
}
|
||||
entry := m.app.State.MessageHistory[index]
|
||||
m.RestoreFromPrompt(entry)
|
||||
}
|
||||
|
||||
func getMediaTypeFromExtension(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg":
|
||||
return "image/jpeg"
|
||||
case ".png", ".jpeg", ".gif", ".webp":
|
||||
return "image/" + ext[1:]
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
default:
|
||||
return "text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
mediaType := getMediaTypeFromExtension(ext)
|
||||
absolutePath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
|
||||
}
|
||||
|
||||
// For text files, create a simple file reference
|
||||
if mediaType == "text/plain" {
|
||||
return &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", filePath),
|
||||
Filename: filePath,
|
||||
MediaType: mediaType,
|
||||
Source: &attachment.FileSource{
|
||||
Path: absolutePath,
|
||||
Mime: mediaType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// For binary files (images, PDFs), read and encode
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read file", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
|
||||
url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
|
||||
attachmentCount := len(m.textarea.GetAttachments())
|
||||
attachmentIndex := attachmentCount + 1
|
||||
label := "File"
|
||||
if strings.HasPrefix(mediaType, "image/") {
|
||||
label = "Image"
|
||||
}
|
||||
return &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
MediaType: mediaType,
|
||||
Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
|
||||
URL: url,
|
||||
Filename: filePath,
|
||||
Source: &attachment.FileSource{
|
||||
Path: absolutePath,
|
||||
Mime: mediaType,
|
||||
Data: fileBytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
|
||||
extension := filepath.Ext(filePath)
|
||||
mediaType := getMediaTypeFromExtension(extension)
|
||||
absolutePath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
|
||||
}
|
||||
return &attachment.Attachment{
|
||||
ID: uuid.NewString(),
|
||||
Type: "file",
|
||||
Display: "@" + filePath,
|
||||
URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
|
||||
Filename: filePath,
|
||||
MediaType: mediaType,
|
||||
Source: &attachment.FileSource{
|
||||
Path: absolutePath,
|
||||
Mime: mediaType,
|
||||
},
|
||||
}
|
||||
}
|
||||
+87
-96
@@ -9,11 +9,11 @@ import (
|
||||
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2/compat"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/diff"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
@@ -109,7 +109,6 @@ func WithPaddingBottom(padding int) renderingOption {
|
||||
func renderContentBlock(
|
||||
app *app.App,
|
||||
content string,
|
||||
highlight bool,
|
||||
width int,
|
||||
options ...renderingOption,
|
||||
) string {
|
||||
@@ -158,18 +157,6 @@ func renderContentBlock(
|
||||
BorderRightBackground(t.Background())
|
||||
}
|
||||
|
||||
if highlight {
|
||||
style = style.
|
||||
BorderLeftForeground(borderColor).
|
||||
BorderRightForeground(borderColor)
|
||||
}
|
||||
}
|
||||
|
||||
if highlight {
|
||||
style = style.
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundElement()).
|
||||
Bold(true)
|
||||
}
|
||||
|
||||
content = style.Render(content)
|
||||
@@ -184,32 +171,6 @@ func renderContentBlock(
|
||||
}
|
||||
}
|
||||
|
||||
if highlight {
|
||||
copy := app.Key(commands.MessagesCopyCommand)
|
||||
// revert := app.Key(commands.MessagesRevertCommand)
|
||||
|
||||
background := t.Background()
|
||||
header := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &background,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifyCenter,
|
||||
Align: layout.AlignStretch,
|
||||
Width: width - 2,
|
||||
Gap: 5,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: copy,
|
||||
},
|
||||
// layout.FlexItem{
|
||||
// View: revert,
|
||||
// },
|
||||
)
|
||||
header = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(header)
|
||||
|
||||
content = "\n\n\n" + header + "\n\n" + content + "\n\n\n"
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -219,7 +180,6 @@ func renderText(
|
||||
text string,
|
||||
author string,
|
||||
showToolDetails bool,
|
||||
highlight bool,
|
||||
width int,
|
||||
extra string,
|
||||
toolCalls ...opencode.ToolPart,
|
||||
@@ -228,9 +188,6 @@ func renderText(
|
||||
|
||||
var ts time.Time
|
||||
backgroundColor := t.BackgroundPanel()
|
||||
if highlight {
|
||||
backgroundColor = t.BackgroundElement()
|
||||
}
|
||||
var content string
|
||||
switch casted := message.(type) {
|
||||
case opencode.AssistantMessage:
|
||||
@@ -238,8 +195,22 @@ func renderText(
|
||||
content = util.ToMarkdown(text, width, backgroundColor)
|
||||
case opencode.UserMessage:
|
||||
ts = time.UnixMilli(int64(casted.Time.Created))
|
||||
messageStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6)
|
||||
content = messageStyle.Render(text)
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
|
||||
text = ansi.WordwrapWc(text, width-6, " -")
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
words := strings.Fields(line)
|
||||
for i, word := range words {
|
||||
if strings.HasPrefix(word, "@") {
|
||||
words[i] = base.Foreground(t.Secondary()).Render(word + " ")
|
||||
} else {
|
||||
words[i] = base.Render(word + " ")
|
||||
}
|
||||
}
|
||||
lines[i] = strings.Join(words, "")
|
||||
}
|
||||
text = strings.Join(lines, "\n")
|
||||
content = base.Width(width - 6).Render(text)
|
||||
}
|
||||
|
||||
timestamp := ts.
|
||||
@@ -277,7 +248,6 @@ func renderText(
|
||||
return renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
highlight,
|
||||
width,
|
||||
WithTextColor(t.Text()),
|
||||
WithBorderColorRight(t.Secondary()),
|
||||
@@ -286,7 +256,6 @@ func renderText(
|
||||
return renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
highlight,
|
||||
width,
|
||||
WithBorderColor(t.Accent()),
|
||||
)
|
||||
@@ -297,19 +266,18 @@ func renderText(
|
||||
func renderToolDetails(
|
||||
app *app.App,
|
||||
toolCall opencode.ToolPart,
|
||||
highlight bool,
|
||||
width int,
|
||||
) string {
|
||||
measure := util.Measure("chat.renderToolDetails")
|
||||
defer measure("tool", toolCall.Tool)
|
||||
ignoredTools := []string{"todoread"}
|
||||
if slices.Contains(ignoredTools, toolCall.Tool) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending ||
|
||||
toolCall.State.Status == opencode.ToolPartStateStatusRunning {
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
|
||||
title := renderToolTitle(toolCall, width)
|
||||
title = styles.NewStyle().Width(width - 6).Render(title)
|
||||
return renderContentBlock(app, title, highlight, width)
|
||||
return renderContentBlock(app, title, width)
|
||||
}
|
||||
|
||||
var result *string
|
||||
@@ -334,16 +302,16 @@ func renderToolDetails(
|
||||
t := theme.CurrentTheme()
|
||||
backgroundColor := t.BackgroundPanel()
|
||||
borderColor := t.BackgroundPanel()
|
||||
if highlight {
|
||||
backgroundColor = t.BackgroundElement()
|
||||
borderColor = t.BorderActive()
|
||||
}
|
||||
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
|
||||
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
|
||||
if toolCall.State.Metadata != nil {
|
||||
metadata := toolCall.State.Metadata.(map[string]any)
|
||||
switch toolCall.Tool {
|
||||
case "read":
|
||||
preview := metadata["preview"]
|
||||
var preview any
|
||||
if metadata != nil {
|
||||
preview = metadata["preview"]
|
||||
}
|
||||
if preview != nil && toolInputMap["filePath"] != nil {
|
||||
filename := toolInputMap["filePath"].(string)
|
||||
body = preview.(string)
|
||||
@@ -351,26 +319,34 @@ func renderToolDetails(
|
||||
}
|
||||
case "edit":
|
||||
if filename, ok := toolInputMap["filePath"].(string); ok {
|
||||
diffField := metadata["diff"]
|
||||
var diffField any
|
||||
if metadata != nil {
|
||||
diffField = metadata["diff"]
|
||||
}
|
||||
if diffField != nil {
|
||||
patch := diffField.(string)
|
||||
var formattedDiff string
|
||||
formattedDiff, _ = diff.FormatUnifiedDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(width-2),
|
||||
)
|
||||
if width < 120 {
|
||||
formattedDiff, _ = diff.FormatUnifiedDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(width-2),
|
||||
)
|
||||
} else {
|
||||
formattedDiff, _ = diff.FormatDiff(
|
||||
filename,
|
||||
patch,
|
||||
diff.WithWidth(width-2),
|
||||
)
|
||||
}
|
||||
body = strings.TrimSpace(formattedDiff)
|
||||
style := styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.TextMuted()).
|
||||
Padding(1, 2).
|
||||
Width(width - 4)
|
||||
if highlight {
|
||||
style = style.Foreground(t.Text()).Bold(true)
|
||||
}
|
||||
|
||||
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
||||
if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
|
||||
diagnostics = style.Render(diagnostics)
|
||||
body += "\n" + diagnostics
|
||||
}
|
||||
@@ -381,7 +357,6 @@ func renderToolDetails(
|
||||
content = renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
highlight,
|
||||
width,
|
||||
WithPadding(0),
|
||||
WithBorderColor(borderColor),
|
||||
@@ -393,18 +368,20 @@ func renderToolDetails(
|
||||
if filename, ok := toolInputMap["filePath"].(string); ok {
|
||||
if content, ok := toolInputMap["content"].(string); ok {
|
||||
body = util.RenderFile(filename, content, width)
|
||||
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
|
||||
if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" {
|
||||
body += "\n\n" + diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
command := toolInputMap["command"].(string)
|
||||
body = fmt.Sprintf("```console\n$ %s\n", command)
|
||||
stdout := metadata["stdout"]
|
||||
if stdout != nil {
|
||||
command := toolInputMap["command"].(string)
|
||||
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
body += ansi.Strip(fmt.Sprintf("%s", stdout))
|
||||
}
|
||||
body += "```"
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
case "webfetch":
|
||||
if format, ok := toolInputMap["format"].(string); ok && result != nil {
|
||||
body = *result
|
||||
@@ -424,7 +401,7 @@ func renderToolDetails(
|
||||
body += fmt.Sprintf("- [x] %s\n", content)
|
||||
case "cancelled":
|
||||
// strike through cancelled todo
|
||||
body += fmt.Sprintf("- [~] ~~%s~~\n", content)
|
||||
body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
|
||||
case "in_progress":
|
||||
// highlight in progress todo
|
||||
body += fmt.Sprintf("- [ ] `%s`\n", content)
|
||||
@@ -439,19 +416,17 @@ func renderToolDetails(
|
||||
if summary != nil {
|
||||
toolcalls := summary.([]any)
|
||||
steps := []string{}
|
||||
for _, toolcall := range toolcalls {
|
||||
call := toolcall.(map[string]any)
|
||||
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
|
||||
data, _ := json.Marshal(toolInvocation)
|
||||
var toolCall opencode.ToolPart
|
||||
_ = json.Unmarshal(data, &toolCall)
|
||||
step := renderToolTitle(toolCall, width)
|
||||
step = "∟ " + step
|
||||
steps = append(steps, step)
|
||||
}
|
||||
for _, item := range toolcalls {
|
||||
data, _ := json.Marshal(item)
|
||||
var toolCall opencode.ToolPart
|
||||
_ = json.Unmarshal(data, &toolCall)
|
||||
step := renderToolTitle(toolCall, width)
|
||||
step = "∟ " + step
|
||||
steps = append(steps, step)
|
||||
}
|
||||
body = strings.Join(steps, "\n")
|
||||
}
|
||||
body = defaultStyle(body)
|
||||
default:
|
||||
if result == nil {
|
||||
empty := ""
|
||||
@@ -459,7 +434,7 @@ func renderToolDetails(
|
||||
}
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
body = styles.NewStyle().Width(width - 6).Render(body)
|
||||
body = defaultStyle(body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,12 +454,16 @@ func renderToolDetails(
|
||||
if body == "" && error == "" && result != nil {
|
||||
body = *result
|
||||
body = util.TruncateHeight(body, 10)
|
||||
body = styles.NewStyle().Width(width - 6).Render(body)
|
||||
body = defaultStyle(body)
|
||||
}
|
||||
|
||||
if body == "" {
|
||||
body = defaultStyle("")
|
||||
}
|
||||
|
||||
title := renderToolTitle(toolCall, width)
|
||||
content := title + "\n\n" + body
|
||||
return renderContentBlock(app, content, highlight, width, WithBorderColor(borderColor))
|
||||
return renderContentBlock(app, content, width, WithBorderColor(borderColor))
|
||||
}
|
||||
|
||||
func renderToolName(name string) string {
|
||||
@@ -539,10 +518,9 @@ func renderToolTitle(
|
||||
toolCall opencode.ToolPart,
|
||||
width int,
|
||||
) string {
|
||||
// TODO: handle truncate to width
|
||||
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
|
||||
return renderToolAction(toolCall.Tool)
|
||||
title := renderToolAction(toolCall.Tool)
|
||||
return styles.NewStyle().Width(width - 6).Render(title)
|
||||
}
|
||||
|
||||
toolArgs := ""
|
||||
@@ -590,13 +568,15 @@ func renderToolTitle(
|
||||
toolName := renderToolName(toolCall.Tool)
|
||||
title = fmt.Sprintf("%s %s", toolName, toolArgs)
|
||||
}
|
||||
|
||||
title = truncate.StringWithTail(title, uint(width-6), "...")
|
||||
return title
|
||||
}
|
||||
|
||||
func renderToolAction(name string) string {
|
||||
switch name {
|
||||
case "task":
|
||||
return "Searching..."
|
||||
return "Planning..."
|
||||
case "bash":
|
||||
return "Writing command..."
|
||||
case "edit":
|
||||
@@ -667,7 +647,12 @@ type Diagnostic struct {
|
||||
}
|
||||
|
||||
// renderDiagnostics formats LSP diagnostics for display in the TUI
|
||||
func renderDiagnostics(metadata map[string]any, filePath string) string {
|
||||
func renderDiagnostics(
|
||||
metadata map[string]any,
|
||||
filePath string,
|
||||
backgroundColor compat.AdaptiveColor,
|
||||
width int,
|
||||
) string {
|
||||
if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
|
||||
if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
|
||||
var errorDiagnostics []string
|
||||
@@ -703,9 +688,15 @@ func renderDiagnostics(metadata map[string]any, filePath string) string {
|
||||
var result strings.Builder
|
||||
for _, diagnostic := range errorDiagnostics {
|
||||
if result.Len() > 0 {
|
||||
result.WriteString("\n")
|
||||
result.WriteString("\n\n")
|
||||
}
|
||||
result.WriteString(styles.NewStyle().Foreground(t.Error()).Render(diagnostic))
|
||||
diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
|
||||
result.WriteString(
|
||||
styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.Error()).
|
||||
Render(diagnostic),
|
||||
)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+37
@@ -2,6 +2,7 @@ package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type CommandsComponent interface {
|
||||
@@ -24,6 +26,7 @@ type commandsComponent struct {
|
||||
width, height int
|
||||
showKeybinds bool
|
||||
showAll bool
|
||||
showVscode bool
|
||||
background *compat.AdaptiveColor
|
||||
limit *int
|
||||
}
|
||||
@@ -73,6 +76,34 @@ func (c *commandsComponent) View() string {
|
||||
commandsToShow = commandsToShow[:*c.limit]
|
||||
}
|
||||
|
||||
if c.showVscode {
|
||||
ctrlKey := "ctrl"
|
||||
if runtime.GOOS == "darwin" {
|
||||
ctrlKey = "cmd"
|
||||
}
|
||||
commandsToShow = append(commandsToShow,
|
||||
// empty line
|
||||
commands.Command{
|
||||
Name: "",
|
||||
Description: "",
|
||||
},
|
||||
commands.Command{
|
||||
Name: commands.CommandName(util.Ide()),
|
||||
Description: "open opencode",
|
||||
Keybindings: []commands.Keybinding{
|
||||
{Key: ctrlKey + "+esc", RequiresLeader: false},
|
||||
},
|
||||
},
|
||||
commands.Command{
|
||||
Name: commands.CommandName(util.Ide()),
|
||||
Description: "reference file",
|
||||
Keybindings: []commands.Keybinding{
|
||||
{Key: ctrlKey + "+opt+k", RequiresLeader: false},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if len(commandsToShow) == 0 {
|
||||
muted := styles.NewStyle().Foreground(theme.CurrentTheme().TextMuted())
|
||||
if c.showAll {
|
||||
@@ -196,6 +227,12 @@ func WithShowAll(showAll bool) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithVscode(showVscode bool) Option {
|
||||
return func(c *commandsComponent) {
|
||||
c.showVscode = showVscode
|
||||
}
|
||||
}
|
||||
|
||||
func New(app *app.App, opts ...Option) CommandsComponent {
|
||||
c := &commandsComponent{
|
||||
app: app,
|
||||
+52
-84
@@ -11,73 +11,15 @@ import (
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
type CompletionItem struct {
|
||||
Title string
|
||||
Value string
|
||||
ProviderID string
|
||||
Raw any
|
||||
}
|
||||
|
||||
type CompletionItemI interface {
|
||||
list.ListItem
|
||||
GetValue() string
|
||||
DisplayValue() string
|
||||
GetProviderID() string
|
||||
GetRaw() any
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) Render(selected bool, width int) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||
|
||||
truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4))
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundElement()).
|
||||
Padding(0, 1)
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.Foreground(t.Primary())
|
||||
}
|
||||
|
||||
title := itemStyle.Render(truncatedStr)
|
||||
return title
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) DisplayValue() string {
|
||||
return ci.Title
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) GetValue() string {
|
||||
return ci.Value
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) GetProviderID() string {
|
||||
return ci.ProviderID
|
||||
}
|
||||
|
||||
func (ci *CompletionItem) GetRaw() any {
|
||||
return ci.Raw
|
||||
}
|
||||
|
||||
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
|
||||
return &completionItem
|
||||
}
|
||||
|
||||
type CompletionProvider interface {
|
||||
GetId() string
|
||||
GetChildEntries(query string) ([]CompletionItemI, error)
|
||||
GetEmptyMessage() string
|
||||
}
|
||||
|
||||
type CompletionSelectedMsg struct {
|
||||
Item CompletionItemI
|
||||
Item completions.CompletionSuggestion
|
||||
SearchString string
|
||||
}
|
||||
|
||||
@@ -96,11 +38,11 @@ type CompletionDialog interface {
|
||||
|
||||
type completionDialogComponent struct {
|
||||
query string
|
||||
providers []CompletionProvider
|
||||
providers []completions.CompletionProvider
|
||||
width int
|
||||
height int
|
||||
pseudoSearchTextArea textarea.Model
|
||||
list list.List[CompletionItemI]
|
||||
list list.List[completions.CompletionSuggestion]
|
||||
trigger string
|
||||
}
|
||||
|
||||
@@ -114,7 +56,7 @@ var completionDialogKeys = completionDialogKeyMap{
|
||||
key.WithKeys("tab", "enter", "right"),
|
||||
),
|
||||
Cancel: key.NewBinding(
|
||||
key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
|
||||
key.WithKeys("space", " ", "esc", "backspace", "ctrl+h", "ctrl+c"),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -124,7 +66,8 @@ func (c *completionDialogComponent) Init() tea.Cmd {
|
||||
|
||||
func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
allItems := make([]CompletionItemI, 0)
|
||||
allItems := make([]completions.CompletionSuggestion, 0)
|
||||
providersWithResults := 0
|
||||
|
||||
// Collect results from all providers
|
||||
for _, provider := range c.providers {
|
||||
@@ -139,25 +82,27 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
|
||||
)
|
||||
continue
|
||||
}
|
||||
allItems = append(allItems, items...)
|
||||
if len(items) > 0 {
|
||||
providersWithResults++
|
||||
allItems = append(allItems, items...)
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a query, use fuzzy ranking to sort results
|
||||
if query != "" && len(allItems) > 0 {
|
||||
if query != "" && providersWithResults > 1 {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
|
||||
// Create a slice of display values for fuzzy matching
|
||||
displayValues := make([]string, len(allItems))
|
||||
for i, item := range allItems {
|
||||
displayValues[i] = item.DisplayValue()
|
||||
displayValues[i] = item.Display(baseStyle)
|
||||
}
|
||||
|
||||
// Get fuzzy matches with ranking
|
||||
matches := fuzzy.RankFindFold(query, displayValues)
|
||||
|
||||
// Sort by score (best matches first)
|
||||
sort.Sort(matches)
|
||||
|
||||
// Reorder items based on fuzzy ranking
|
||||
rankedItems := make([]CompletionItemI, 0, len(matches))
|
||||
rankedItems := make([]completions.CompletionSuggestion, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
rankedItems = append(rankedItems, allItems[match.OriginalIndex])
|
||||
}
|
||||
@@ -171,7 +116,7 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
|
||||
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case []CompletionItemI:
|
||||
case []completions.CompletionSuggestion:
|
||||
c.list.SetItems(msg)
|
||||
case tea.KeyMsg:
|
||||
if c.pseudoSearchTextArea.Focused() {
|
||||
@@ -189,7 +134,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
u, cmd := c.list.Update(msg)
|
||||
c.list = u.(list.List[CompletionItemI])
|
||||
c.list = u.(list.List[completions.CompletionSuggestion])
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
@@ -205,7 +150,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
width := lipgloss.Width(value)
|
||||
triggerWidth := lipgloss.Width(c.trigger)
|
||||
// Only close on backspace when there are no characters left, unless we're back to just the trigger
|
||||
if msg.String() != "backspace" || (width <= triggerWidth && value != c.trigger) {
|
||||
if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
|
||||
return c, c.close()
|
||||
}
|
||||
}
|
||||
@@ -223,11 +168,11 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (c *completionDialogComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||
c.list.SetMaxWidth(c.width)
|
||||
|
||||
return baseStyle.
|
||||
Padding(0, 0).
|
||||
return styles.NewStyle().
|
||||
Padding(0, 1).
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundElement()).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
@@ -246,7 +191,7 @@ func (c *completionDialogComponent) IsEmpty() bool {
|
||||
return c.list.IsEmpty()
|
||||
}
|
||||
|
||||
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
|
||||
func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd {
|
||||
value := c.pseudoSearchTextArea.Value()
|
||||
return tea.Batch(
|
||||
util.CmdHandler(CompletionSelectedMsg{
|
||||
@@ -265,7 +210,7 @@ func (c *completionDialogComponent) close() tea.Cmd {
|
||||
|
||||
func NewCompletionDialogComponent(
|
||||
trigger string,
|
||||
providers ...CompletionProvider,
|
||||
providers ...completions.CompletionProvider,
|
||||
) CompletionDialog {
|
||||
ti := textarea.New()
|
||||
ti.SetValue(trigger)
|
||||
@@ -276,11 +221,34 @@ func NewCompletionDialogComponent(
|
||||
emptyMessage = providers[0].GetEmptyMessage()
|
||||
}
|
||||
|
||||
// Define render function for completion suggestions
|
||||
renderFunc := func(item completions.CompletionSuggestion, selected bool, width int, baseStyle styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
style := baseStyle
|
||||
|
||||
if selected {
|
||||
style = style.Background(t.BackgroundElement()).Foreground(t.Primary())
|
||||
} else {
|
||||
style = style.Background(t.BackgroundElement()).Foreground(t.Text())
|
||||
}
|
||||
|
||||
// The item.Display string already has any inline colors from the provider
|
||||
truncatedStr := truncate.String(item.Display(style), uint(width-4))
|
||||
return style.Width(width - 4).Render(truncatedStr)
|
||||
}
|
||||
|
||||
// Define selectable function - all completion suggestions are selectable
|
||||
selectableFunc := func(item completions.CompletionSuggestion) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
li := list.NewListComponent(
|
||||
[]CompletionItemI{},
|
||||
7,
|
||||
emptyMessage,
|
||||
false,
|
||||
list.WithItems([]completions.CompletionSuggestion{}),
|
||||
list.WithMaxVisibleHeight[completions.CompletionSuggestion](7),
|
||||
list.WithFallbackMessage[completions.CompletionSuggestion](emptyMessage),
|
||||
list.WithAlphaNumericKeys[completions.CompletionSuggestion](false),
|
||||
list.WithRenderFunc(renderFunc),
|
||||
list.WithSelectableFunc(selectableFunc),
|
||||
)
|
||||
|
||||
c := &completionDialogComponent{
|
||||
@@ -293,7 +261,7 @@ func NewCompletionDialogComponent(
|
||||
|
||||
// Load initial items from all providers
|
||||
go func() {
|
||||
allItems := make([]CompletionItemI, 0)
|
||||
allItems := make([]completions.CompletionSuggestion, 0)
|
||||
for _, provider := range providers {
|
||||
items, err := provider.GetChildEntries("")
|
||||
if err != nil {
|
||||
@@ -0,0 +1,236 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
findDialogWidth = 76
|
||||
)
|
||||
|
||||
type FindSelectedMsg struct {
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type FindDialogCloseMsg struct{}
|
||||
|
||||
type findInitialSuggestionsMsg struct {
|
||||
suggestions []completions.CompletionSuggestion
|
||||
}
|
||||
|
||||
type FindDialog interface {
|
||||
layout.Modal
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetWidth(width int)
|
||||
SetHeight(height int)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
// findItem is a custom list item for file suggestions
|
||||
type findItem struct {
|
||||
suggestion completions.CompletionSuggestion
|
||||
}
|
||||
|
||||
func (f findItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.TextMuted())
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.Foreground(t.Primary())
|
||||
}
|
||||
|
||||
return itemStyle.PaddingLeft(1).Render(f.suggestion.Display(itemStyle))
|
||||
}
|
||||
|
||||
func (f findItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type findDialogComponent struct {
|
||||
completionProvider completions.CompletionProvider
|
||||
allSuggestions []completions.CompletionSuggestion
|
||||
width, height int
|
||||
modal *modal.Modal
|
||||
searchDialog *SearchDialog
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
f.loadInitialSuggestions(),
|
||||
f.searchDialog.Init(),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) loadInitialSuggestions() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get initial completion items", "error", err)
|
||||
return findInitialSuggestionsMsg{suggestions: []completions.CompletionSuggestion{}}
|
||||
}
|
||||
return findInitialSuggestionsMsg{suggestions: items}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case findInitialSuggestionsMsg:
|
||||
// Handle initial suggestions setup
|
||||
f.allSuggestions = msg.suggestions
|
||||
|
||||
// Calculate dialog width
|
||||
f.dialogWidth = f.calculateDialogWidth()
|
||||
|
||||
// Initialize search dialog with calculated width
|
||||
f.searchDialog = NewSearchDialog("Search files...", 10)
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
|
||||
// Convert to list items
|
||||
items := make([]list.Item, len(f.allSuggestions))
|
||||
for i, suggestion := range f.allSuggestions {
|
||||
items[i] = findItem{suggestion: suggestion}
|
||||
}
|
||||
f.searchDialog.SetItems(items)
|
||||
|
||||
// Update modal with calculated width
|
||||
f.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(f.dialogWidth+4),
|
||||
)
|
||||
|
||||
return f, f.searchDialog.Init()
|
||||
|
||||
case []completions.CompletionSuggestion:
|
||||
// Store suggestions and convert to findItem for the search dialog
|
||||
f.allSuggestions = msg
|
||||
items := make([]list.Item, len(msg))
|
||||
for i, suggestion := range msg {
|
||||
items[i] = findItem{suggestion: suggestion}
|
||||
}
|
||||
f.searchDialog.SetItems(items)
|
||||
return f, nil
|
||||
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog - now we can directly access the suggestion
|
||||
if item, ok := msg.Item.(findItem); ok {
|
||||
return f, f.selectFile(item.suggestion)
|
||||
}
|
||||
return f, nil
|
||||
|
||||
case SearchCancelledMsg:
|
||||
return f, f.Close()
|
||||
|
||||
case SearchQueryChangedMsg:
|
||||
// Update completion items based on search query
|
||||
return f, func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries(msg.Query)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
return []completions.CompletionSuggestion{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
f.width = msg.Width
|
||||
f.height = msg.Height
|
||||
// Recalculate width based on new viewport size
|
||||
oldWidth := f.dialogWidth
|
||||
f.dialogWidth = f.calculateDialogWidth()
|
||||
if oldWidth != f.dialogWidth {
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
// Update modal max width too
|
||||
f.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(f.dialogWidth+4),
|
||||
)
|
||||
}
|
||||
f.searchDialog.SetHeight(msg.Height)
|
||||
}
|
||||
|
||||
// Forward all other messages to the search dialog
|
||||
updatedDialog, cmd := f.searchDialog.Update(msg)
|
||||
f.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return f, cmd
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) View() string {
|
||||
return f.searchDialog.View()
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) calculateDialogWidth() int {
|
||||
// Use fixed width unless viewport is smaller
|
||||
if f.width > 0 && f.width < findDialogWidth+10 {
|
||||
return f.width - 10
|
||||
}
|
||||
return findDialogWidth
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetWidth(width int) {
|
||||
f.width = width
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetHeight(height int) {
|
||||
f.height = height
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) IsEmpty() bool {
|
||||
return f.searchDialog.GetQuery() == ""
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) selectFile(item completions.CompletionSuggestion) tea.Cmd {
|
||||
return tea.Sequence(
|
||||
f.Close(),
|
||||
util.CmdHandler(FindSelectedMsg{
|
||||
FilePath: item.Value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Render(background string) string {
|
||||
return f.modal.Render(f.View(), background)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Close() tea.Cmd {
|
||||
f.searchDialog.SetQuery("")
|
||||
f.searchDialog.Blur()
|
||||
return util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
|
||||
func NewFindDialog(completionProvider completions.CompletionProvider) FindDialog {
|
||||
component := &findDialogComponent{
|
||||
completionProvider: completionProvider,
|
||||
dialogWidth: findDialogWidth,
|
||||
allSuggestions: []completions.CompletionSuggestion{},
|
||||
}
|
||||
|
||||
// Create search dialog and modal with fixed width
|
||||
component.searchDialog = NewSearchDialog("Search files...", 10)
|
||||
component.searchDialog.SetWidth(findDialogWidth)
|
||||
|
||||
component.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(findDialogWidth+4),
|
||||
)
|
||||
|
||||
return component
|
||||
}
|
||||
+1
-1
@@ -1,13 +1,13 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
commandsComponent "github.com/sst/opencode/internal/components/commands"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/viewport"
|
||||
)
|
||||
|
||||
type helpDialog struct {
|
||||
@@ -0,0 +1,457 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
numVisibleModels = 10
|
||||
minDialogWidth = 40
|
||||
maxDialogWidth = 80
|
||||
maxRecentModels = 5
|
||||
)
|
||||
|
||||
// ModelDialog interface for the model selection dialog
|
||||
type ModelDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
type modelDialog struct {
|
||||
app *app.App
|
||||
allModels []ModelWithProvider
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
searchDialog *SearchDialog
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
type ModelWithProvider struct {
|
||||
Model opencode.Model
|
||||
Provider opencode.Provider
|
||||
}
|
||||
|
||||
// modelItem is a custom list item for model selections
|
||||
type modelItem struct {
|
||||
model ModelWithProvider
|
||||
}
|
||||
|
||||
func (m modelItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.Text())
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.Foreground(t.Primary())
|
||||
}
|
||||
|
||||
providerStyle := baseStyle.
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundPanel())
|
||||
|
||||
modelPart := itemStyle.Render(m.model.Model.Name)
|
||||
providerPart := providerStyle.Render(fmt.Sprintf(" %s", m.model.Provider.Name))
|
||||
|
||||
combinedText := modelPart + providerPart
|
||||
return baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
PaddingLeft(1).
|
||||
Render(combinedText)
|
||||
}
|
||||
|
||||
func (m modelItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type modelKeyMap struct {
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
}
|
||||
|
||||
var modelKeys = modelKeyMap{
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select model"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "close"),
|
||||
),
|
||||
}
|
||||
|
||||
func (m *modelDialog) Init() tea.Cmd {
|
||||
m.setupAllModels()
|
||||
return m.searchDialog.Init()
|
||||
}
|
||||
|
||||
func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog
|
||||
if item, ok := msg.Item.(modelItem); ok {
|
||||
return m, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(
|
||||
app.ModelSelectedMsg{
|
||||
Provider: item.model.Provider,
|
||||
Model: item.model.Model,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
case SearchCancelledMsg:
|
||||
return m, util.CmdHandler(modal.CloseModalMsg{})
|
||||
|
||||
case SearchRemoveItemMsg:
|
||||
if item, ok := msg.Item.(modelItem); ok {
|
||||
if m.isModelInRecentSection(item.model, msg.Index) {
|
||||
m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID)
|
||||
items := m.buildDisplayList(m.searchDialog.GetQuery())
|
||||
m.searchDialog.SetItems(items)
|
||||
return m, m.app.SaveState()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case SearchQueryChangedMsg:
|
||||
// Update the list based on search query
|
||||
items := m.buildDisplayList(msg.Query)
|
||||
m.searchDialog.SetItems(items)
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.searchDialog.SetWidth(m.dialogWidth)
|
||||
m.searchDialog.SetHeight(msg.Height)
|
||||
}
|
||||
|
||||
updatedDialog, cmd := m.searchDialog.Update(msg)
|
||||
m.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *modelDialog) View() string {
|
||||
return m.searchDialog.View()
|
||||
}
|
||||
|
||||
func (m *modelDialog) calculateOptimalWidth(models []ModelWithProvider) int {
|
||||
maxWidth := minDialogWidth
|
||||
|
||||
for _, model := range models {
|
||||
// Calculate the width needed for this item: "ModelName (ProviderName)"
|
||||
// Add 4 for the parentheses, space, and some padding
|
||||
itemWidth := len(model.Model.Name) + len(model.Provider.Name) + 4
|
||||
if itemWidth > maxWidth {
|
||||
maxWidth = itemWidth
|
||||
}
|
||||
}
|
||||
|
||||
if maxWidth > maxDialogWidth {
|
||||
maxWidth = maxDialogWidth
|
||||
}
|
||||
|
||||
return maxWidth
|
||||
}
|
||||
|
||||
func (m *modelDialog) setupAllModels() {
|
||||
providers, _ := m.app.ListProviders(context.Background())
|
||||
|
||||
m.allModels = make([]ModelWithProvider, 0)
|
||||
for _, provider := range providers {
|
||||
for _, model := range provider.Models {
|
||||
m.allModels = append(m.allModels, ModelWithProvider{
|
||||
Model: model,
|
||||
Provider: provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m.sortModels()
|
||||
|
||||
// Calculate optimal width based on all models
|
||||
m.dialogWidth = m.calculateOptimalWidth(m.allModels)
|
||||
|
||||
// Initialize search dialog
|
||||
m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
|
||||
m.searchDialog.SetWidth(m.dialogWidth)
|
||||
|
||||
// Build initial display list (empty query shows grouped view)
|
||||
items := m.buildDisplayList("")
|
||||
m.searchDialog.SetItems(items)
|
||||
}
|
||||
|
||||
func (m *modelDialog) sortModels() {
|
||||
sort.Slice(m.allModels, func(i, j int) bool {
|
||||
modelA := m.allModels[i]
|
||||
modelB := m.allModels[j]
|
||||
|
||||
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
|
||||
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
|
||||
|
||||
// If both have usage times, sort by most recent first
|
||||
if !usageA.IsZero() && !usageB.IsZero() {
|
||||
return usageA.After(usageB)
|
||||
}
|
||||
|
||||
// If only one has usage time, it goes first
|
||||
if !usageA.IsZero() && usageB.IsZero() {
|
||||
return true
|
||||
}
|
||||
if usageA.IsZero() && !usageB.IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
// If neither has usage time, sort by release date desc if available
|
||||
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
|
||||
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
|
||||
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
|
||||
if !dateA.IsZero() && !dateB.IsZero() {
|
||||
return dateA.After(dateB)
|
||||
}
|
||||
}
|
||||
|
||||
// If only one has release date, it goes first
|
||||
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
|
||||
return true
|
||||
}
|
||||
if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// If neither has usage time nor release date, fall back to alphabetical sorting
|
||||
return modelA.Model.Name < modelB.Model.Name
|
||||
})
|
||||
}
|
||||
|
||||
func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
|
||||
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
|
||||
for _, usage := range m.app.State.RecentlyUsedModels {
|
||||
if usage.ProviderID == providerID && usage.ModelID == modelID {
|
||||
return usage.LastUsed
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// buildDisplayList creates the list items based on search query
|
||||
func (m *modelDialog) buildDisplayList(query string) []list.Item {
|
||||
if query != "" {
|
||||
// Search mode: use fuzzy matching
|
||||
return m.buildSearchResults(query)
|
||||
} else {
|
||||
// Grouped mode: show Recent section and provider groups
|
||||
return m.buildGroupedResults()
|
||||
}
|
||||
}
|
||||
|
||||
// buildSearchResults creates a flat list of search results using fuzzy matching
|
||||
func (m *modelDialog) buildSearchResults(query string) []list.Item {
|
||||
type modelMatch struct {
|
||||
model ModelWithProvider
|
||||
score int
|
||||
}
|
||||
|
||||
modelNames := []string{}
|
||||
modelMap := make(map[string]ModelWithProvider)
|
||||
|
||||
// Create search strings and perform fuzzy matching
|
||||
for _, model := range m.allModels {
|
||||
searchStr := fmt.Sprintf("%s %s", model.Model.Name, model.Provider.Name)
|
||||
modelNames = append(modelNames, searchStr)
|
||||
modelMap[searchStr] = model
|
||||
|
||||
searchStr = fmt.Sprintf("%s %s", model.Provider.Name, model.Model.Name)
|
||||
modelNames = append(modelNames, searchStr)
|
||||
modelMap[searchStr] = model
|
||||
}
|
||||
|
||||
matches := fuzzy.RankFindFold(query, modelNames)
|
||||
sort.Sort(matches)
|
||||
|
||||
items := []list.Item{}
|
||||
seenModels := make(map[string]bool)
|
||||
|
||||
for _, match := range matches {
|
||||
model := modelMap[match.Target]
|
||||
// Create a unique key to avoid duplicates
|
||||
key := fmt.Sprintf("%s:%s", model.Provider.ID, model.Model.ID)
|
||||
if seenModels[key] {
|
||||
continue
|
||||
}
|
||||
seenModels[key] = true
|
||||
items = append(items, modelItem{model: model})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// buildGroupedResults creates a grouped list with Recent section and provider groups
|
||||
func (m *modelDialog) buildGroupedResults() []list.Item {
|
||||
var items []list.Item
|
||||
|
||||
// Add Recent section
|
||||
recentModels := m.getRecentModels(maxRecentModels)
|
||||
if len(recentModels) > 0 {
|
||||
items = append(items, list.HeaderItem("Recent"))
|
||||
for _, model := range recentModels {
|
||||
items = append(items, modelItem{model: model})
|
||||
}
|
||||
}
|
||||
|
||||
// Group models by provider
|
||||
providerGroups := make(map[string][]ModelWithProvider)
|
||||
for _, model := range m.allModels {
|
||||
providerName := model.Provider.Name
|
||||
providerGroups[providerName] = append(providerGroups[providerName], model)
|
||||
}
|
||||
|
||||
// Get sorted provider names for consistent order
|
||||
var providerNames []string
|
||||
for name := range providerGroups {
|
||||
providerNames = append(providerNames, name)
|
||||
}
|
||||
sort.Strings(providerNames)
|
||||
|
||||
// Add provider groups
|
||||
for _, providerName := range providerNames {
|
||||
models := providerGroups[providerName]
|
||||
|
||||
// Sort models within provider group
|
||||
sort.Slice(models, func(i, j int) bool {
|
||||
modelA := models[i]
|
||||
modelB := models[j]
|
||||
|
||||
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
|
||||
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
|
||||
|
||||
// Sort by usage time first, then by release date, then alphabetically
|
||||
if !usageA.IsZero() && !usageB.IsZero() {
|
||||
return usageA.After(usageB)
|
||||
}
|
||||
if !usageA.IsZero() && usageB.IsZero() {
|
||||
return true
|
||||
}
|
||||
if usageA.IsZero() && !usageB.IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sort by release date if available
|
||||
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
|
||||
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
|
||||
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
|
||||
if !dateA.IsZero() && !dateB.IsZero() {
|
||||
return dateA.After(dateB)
|
||||
}
|
||||
}
|
||||
|
||||
return modelA.Model.Name < modelB.Model.Name
|
||||
})
|
||||
|
||||
// Add provider header
|
||||
items = append(items, list.HeaderItem(providerName))
|
||||
|
||||
// Add models in this provider group
|
||||
for _, model := range models {
|
||||
items = append(items, modelItem{model: model})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// getRecentModels returns the most recently used models
|
||||
func (m *modelDialog) getRecentModels(limit int) []ModelWithProvider {
|
||||
var recentModels []ModelWithProvider
|
||||
|
||||
// Get recent models from app state
|
||||
for _, usage := range m.app.State.RecentlyUsedModels {
|
||||
if len(recentModels) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
// Find the corresponding model
|
||||
for _, model := range m.allModels {
|
||||
if model.Provider.ID == usage.ProviderID && model.Model.ID == usage.ModelID {
|
||||
recentModels = append(recentModels, model)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return recentModels
|
||||
}
|
||||
|
||||
func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int) bool {
|
||||
// Only check if we're in grouped mode (no search query)
|
||||
if m.searchDialog.GetQuery() != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
recentModels := m.getRecentModels(maxRecentModels)
|
||||
if len(recentModels) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Index 0 is the "Recent" header, so recent models are at indices 1 to len(recentModels)
|
||||
if index >= 1 && index <= len(recentModels) {
|
||||
if index-1 < len(recentModels) {
|
||||
recentModel := recentModels[index-1]
|
||||
return recentModel.Provider.ID == model.Provider.ID &&
|
||||
recentModel.Model.ID == model.Model.ID
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *modelDialog) Render(background string) string {
|
||||
return m.modal.Render(m.View(), background)
|
||||
}
|
||||
|
||||
func (s *modelDialog) Close() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewModelDialog(app *app.App) ModelDialog {
|
||||
dialog := &modelDialog{
|
||||
app: app,
|
||||
}
|
||||
|
||||
dialog.setupAllModels()
|
||||
|
||||
dialog.modal = modal.New(
|
||||
modal.WithTitle("Select Model"),
|
||||
modal.WithMaxWidth(dialog.dialogWidth+4),
|
||||
)
|
||||
|
||||
return dialog
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"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/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
// SearchQueryChangedMsg is emitted when the search query changes
|
||||
type SearchQueryChangedMsg struct {
|
||||
Query string
|
||||
}
|
||||
|
||||
// SearchSelectionMsg is emitted when an item is selected
|
||||
type SearchSelectionMsg struct {
|
||||
Item any
|
||||
Index int
|
||||
}
|
||||
|
||||
// SearchCancelledMsg is emitted when the search is cancelled
|
||||
type SearchCancelledMsg struct{}
|
||||
|
||||
// SearchRemoveItemMsg is emitted when Ctrl+X is pressed to remove an item
|
||||
type SearchRemoveItemMsg struct {
|
||||
Item any
|
||||
Index int
|
||||
}
|
||||
|
||||
// SearchDialog is a reusable component that combines a text input with a list
|
||||
type SearchDialog struct {
|
||||
textInput textinput.Model
|
||||
list list.List[list.Item]
|
||||
width int
|
||||
height int
|
||||
focused bool
|
||||
}
|
||||
|
||||
type searchKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
Remove key.Binding
|
||||
}
|
||||
|
||||
var searchKeys = searchKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "ctrl+p"),
|
||||
key.WithHelp("↑", "previous item"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "ctrl+n"),
|
||||
key.WithHelp("↓", "next item"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select"),
|
||||
),
|
||||
Escape: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "cancel"),
|
||||
),
|
||||
Remove: key.NewBinding(
|
||||
key.WithKeys("ctrl+x"),
|
||||
key.WithHelp("ctrl+x", "remove from recent"),
|
||||
),
|
||||
}
|
||||
|
||||
// NewSearchDialog creates a new SearchDialog
|
||||
func NewSearchDialog(placeholder string, maxVisibleHeight int) *SearchDialog {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
textColor := t.Text()
|
||||
textMutedColor := t.TextMuted()
|
||||
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
ti.Styles.Blurred.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Blurred.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Placeholder = styles.NewStyle().
|
||||
Foreground(textMutedColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Text = styles.NewStyle().
|
||||
Foreground(textColor).
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Focused.Prompt = styles.NewStyle().
|
||||
Background(bgColor).
|
||||
Lipgloss()
|
||||
ti.Styles.Cursor.Color = t.Primary()
|
||||
ti.VirtualCursor = true
|
||||
|
||||
ti.Prompt = " "
|
||||
ti.CharLimit = -1
|
||||
ti.Focus()
|
||||
|
||||
emptyList := list.NewListComponent(
|
||||
list.WithItems([]list.Item{}),
|
||||
list.WithMaxVisibleHeight[list.Item](maxVisibleHeight),
|
||||
list.WithFallbackMessage[list.Item](" No items"),
|
||||
list.WithAlphaNumericKeys[list.Item](false),
|
||||
list.WithRenderFunc(
|
||||
func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, baseStyle)
|
||||
},
|
||||
),
|
||||
list.WithSelectableFunc(func(item list.Item) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
return &SearchDialog{
|
||||
textInput: ti,
|
||||
list: emptyList,
|
||||
focused: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SearchDialog) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
value := s.textInput.Value()
|
||||
if value == "" {
|
||||
return s, nil
|
||||
}
|
||||
s.textInput.Reset()
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return SearchQueryChangedMsg{Query: ""}
|
||||
})
|
||||
}
|
||||
|
||||
switch {
|
||||
case key.Matches(msg, searchKeys.Escape):
|
||||
return s, func() tea.Msg { return SearchCancelledMsg{} }
|
||||
|
||||
case key.Matches(msg, searchKeys.Enter):
|
||||
if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
|
||||
return s, func() tea.Msg {
|
||||
return SearchSelectionMsg{Item: selectedItem, Index: idx}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Remove):
|
||||
if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
|
||||
return s, func() tea.Msg {
|
||||
return SearchRemoveItemMsg{Item: selectedItem, Index: idx}
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Up):
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[list.Item])
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case key.Matches(msg, searchKeys.Down):
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := s.list.Update(msg)
|
||||
s.list = listModel.(list.List[list.Item])
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
default:
|
||||
oldValue := s.textInput.Value()
|
||||
var cmd tea.Cmd
|
||||
s.textInput, cmd = s.textInput.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
if newValue := s.textInput.Value(); newValue != oldValue {
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return SearchQueryChangedMsg{Query: newValue}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (s *SearchDialog) View() string {
|
||||
s.list.SetMaxWidth(s.width)
|
||||
listView := s.list.View()
|
||||
listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleHeight(), lipgloss.Top, listView)
|
||||
textinput := s.textInput.View()
|
||||
return textinput + "\n\n" + listView
|
||||
}
|
||||
|
||||
// SetWidth sets the width of the search dialog
|
||||
func (s *SearchDialog) SetWidth(width int) {
|
||||
s.width = width
|
||||
s.textInput.SetWidth(width - 2) // Account for padding and borders
|
||||
}
|
||||
|
||||
// SetHeight sets the height of the search dialog
|
||||
func (s *SearchDialog) SetHeight(height int) {
|
||||
s.height = height
|
||||
}
|
||||
|
||||
// SetItems updates the list items
|
||||
func (s *SearchDialog) SetItems(items []list.Item) {
|
||||
s.list.SetItems(items)
|
||||
}
|
||||
|
||||
// GetQuery returns the current search query
|
||||
func (s *SearchDialog) GetQuery() string {
|
||||
return s.textInput.Value()
|
||||
}
|
||||
|
||||
// SetQuery sets the search query
|
||||
func (s *SearchDialog) SetQuery(query string) {
|
||||
s.textInput.SetValue(query)
|
||||
}
|
||||
|
||||
// Focus focuses the search dialog
|
||||
func (s *SearchDialog) Focus() {
|
||||
s.focused = true
|
||||
s.textInput.Focus()
|
||||
}
|
||||
|
||||
// Blur removes focus from the search dialog
|
||||
func (s *SearchDialog) Blur() {
|
||||
s.focused = false
|
||||
s.textInput.Blur()
|
||||
}
|
||||
+66
-12
@@ -28,17 +28,26 @@ type SessionDialog interface {
|
||||
type sessionItem struct {
|
||||
title string
|
||||
isDeleteConfirming bool
|
||||
isCurrentSession bool
|
||||
}
|
||||
|
||||
func (s sessionItem) Render(selected bool, width int) string {
|
||||
func (s sessionItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
isFirstInViewport bool,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle()
|
||||
|
||||
var text string
|
||||
if s.isDeleteConfirming {
|
||||
text = "Press again to confirm delete"
|
||||
} else {
|
||||
text = s.title
|
||||
if s.isCurrentSession {
|
||||
text = "● " + s.title
|
||||
} else {
|
||||
text = s.title
|
||||
}
|
||||
}
|
||||
|
||||
truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
|
||||
@@ -52,6 +61,14 @@ func (s sessionItem) Render(selected bool, width int) string {
|
||||
Foreground(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingLeft(1)
|
||||
} else if s.isCurrentSession {
|
||||
// Different style for current session when selected
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingLeft(1).
|
||||
Bold(true)
|
||||
} else {
|
||||
// Normal selection
|
||||
itemStyle = baseStyle.
|
||||
@@ -66,6 +83,12 @@ func (s sessionItem) Render(selected bool, width int) string {
|
||||
itemStyle = baseStyle.
|
||||
Foreground(t.Error()).
|
||||
PaddingLeft(1)
|
||||
} else if s.isCurrentSession {
|
||||
// Highlight current session when not selected
|
||||
itemStyle = baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
PaddingLeft(1).
|
||||
Bold(true)
|
||||
} else {
|
||||
itemStyle = baseStyle.
|
||||
PaddingLeft(1)
|
||||
@@ -75,6 +98,10 @@ func (s sessionItem) Render(selected bool, width int) string {
|
||||
return itemStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (s sessionItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type sessionDialog struct {
|
||||
width int
|
||||
height int
|
||||
@@ -110,6 +137,13 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
|
||||
)
|
||||
}
|
||||
case "n":
|
||||
s.app.Session = &opencode.Session{}
|
||||
s.app.Messages = []app.Message{}
|
||||
return s, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(app.SessionClearedMsg{}),
|
||||
)
|
||||
case "x", "delete", "backspace":
|
||||
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
|
||||
if s.deleteConfirmation == idx {
|
||||
@@ -150,10 +184,21 @@ func (s *sessionDialog) Render(background string) string {
|
||||
listView := s.list.View()
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
helpStyle := styles.NewStyle().PaddingLeft(1).PaddingTop(1)
|
||||
helpText := styles.NewStyle().Foreground(t.Text()).Render("x/del")
|
||||
helpText = helpText + styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render(" delete session")
|
||||
helpText = helpStyle.Render(helpText)
|
||||
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
|
||||
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
|
||||
|
||||
leftHelp := keyStyle("n") + mutedStyle(" new session")
|
||||
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
|
||||
|
||||
bgColor := t.BackgroundPanel()
|
||||
helpText := layout.Render(layout.FlexOptions{
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Width: layout.Current.Container.Width - 14,
|
||||
Background: &bgColor,
|
||||
}, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
|
||||
|
||||
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
|
||||
|
||||
content := strings.Join([]string{listView, helpText}, "\n")
|
||||
|
||||
@@ -168,6 +213,7 @@ func (s *sessionDialog) updateListItems() {
|
||||
item := sessionItem{
|
||||
title: sess.Title,
|
||||
isDeleteConfirming: s.deleteConfirmation == i,
|
||||
isCurrentSession: s.app.Session != nil && s.app.Session.ID == sess.ID,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
@@ -203,15 +249,23 @@ func NewSessionDialog(app *app.App) SessionDialog {
|
||||
items = append(items, sessionItem{
|
||||
title: sess.Title,
|
||||
isDeleteConfirming: false,
|
||||
isCurrentSession: app.Session != nil && app.Session.ID == sess.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// Create a generic list component
|
||||
listComponent := list.NewListComponent(
|
||||
items,
|
||||
10, // maxVisibleSessions
|
||||
"No sessions available",
|
||||
true, // useAlphaNumericKeys
|
||||
list.WithItems(items),
|
||||
list.WithMaxVisibleHeight[sessionItem](10),
|
||||
list.WithFallbackMessage[sessionItem]("No sessions available"),
|
||||
list.WithAlphaNumericKeys[sessionItem](true),
|
||||
list.WithRenderFunc(
|
||||
func(item sessionItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, false, baseStyle)
|
||||
},
|
||||
),
|
||||
list.WithSelectableFunc(func(item sessionItem) bool {
|
||||
return true
|
||||
}),
|
||||
)
|
||||
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
|
||||
+38
-22
@@ -5,6 +5,7 @@ import (
|
||||
list "github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
@@ -24,7 +25,7 @@ type themeDialog struct {
|
||||
height int
|
||||
|
||||
modal *modal.Modal
|
||||
list list.List[list.StringItem]
|
||||
list list.List[list.Item]
|
||||
originalTheme string
|
||||
themeApplied bool
|
||||
}
|
||||
@@ -42,16 +43,18 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if item, idx := t.list.GetSelectedItem(); idx >= 0 {
|
||||
selectedTheme := string(item)
|
||||
if err := theme.SetTheme(selectedTheme); err != nil {
|
||||
// status.Error(err.Error())
|
||||
return t, nil
|
||||
if stringItem, ok := item.(list.StringItem); ok {
|
||||
selectedTheme := string(stringItem)
|
||||
if err := theme.SetTheme(selectedTheme); err != nil {
|
||||
// status.Error(err.Error())
|
||||
return t, nil
|
||||
}
|
||||
t.themeApplied = true
|
||||
return t, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
|
||||
)
|
||||
}
|
||||
t.themeApplied = true
|
||||
return t, tea.Sequence(
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -61,11 +64,13 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := t.list.Update(msg)
|
||||
t.list = listModel.(list.List[list.StringItem])
|
||||
t.list = listModel.(list.List[list.Item])
|
||||
|
||||
if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
|
||||
theme.SetTheme(string(item))
|
||||
return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(item)})
|
||||
if stringItem, ok := item.(list.StringItem); ok {
|
||||
theme.SetTheme(string(stringItem))
|
||||
return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)})
|
||||
}
|
||||
}
|
||||
return t, cmd
|
||||
}
|
||||
@@ -94,21 +99,32 @@ func NewThemeDialog() ThemeDialog {
|
||||
}
|
||||
}
|
||||
|
||||
list := list.NewStringList(
|
||||
themes,
|
||||
10, // maxVisibleThemes
|
||||
"No themes available",
|
||||
true,
|
||||
// Convert themes to list items
|
||||
items := make([]list.Item, len(themes))
|
||||
for i, theme := range themes {
|
||||
items[i] = list.StringItem(theme)
|
||||
}
|
||||
|
||||
listComponent := list.NewListComponent(
|
||||
list.WithItems(items),
|
||||
list.WithMaxVisibleHeight[list.Item](10),
|
||||
list.WithFallbackMessage[list.Item]("No themes available"),
|
||||
list.WithAlphaNumericKeys[list.Item](true),
|
||||
list.WithRenderFunc(func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, baseStyle)
|
||||
}),
|
||||
list.WithSelectableFunc(func(item list.Item) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
// Set the initial selection to the current theme
|
||||
list.SetSelectedIndex(selectedIdx)
|
||||
listComponent.SetSelectedIndex(selectedIdx)
|
||||
|
||||
// Set the max width for the list to match the modal width
|
||||
list.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
|
||||
|
||||
listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
|
||||
return &themeDialog{
|
||||
list: list,
|
||||
list: listComponent,
|
||||
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
|
||||
originalTheme: currentTheme,
|
||||
themeApplied: false,
|
||||
+4
-1
@@ -38,6 +38,10 @@ const (
|
||||
LineRemoved // Line removed from the old file
|
||||
)
|
||||
|
||||
var (
|
||||
ansiRegex = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
|
||||
)
|
||||
|
||||
// Segment represents a portion of a line for intra-line highlighting
|
||||
type Segment struct {
|
||||
Start int
|
||||
@@ -548,7 +552,6 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
|
||||
// applyHighlighting applies intra-line highlighting to a piece of text
|
||||
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg compat.AdaptiveColor) string {
|
||||
// Find all ANSI sequences in the content
|
||||
ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
|
||||
ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
|
||||
|
||||
// Build a mapping of visible character positions to their actual indices
|
||||
+1
-1
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/app"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/internal/viewport"
|
||||
)
|
||||
|
||||
type DiffStyle int
|
||||
@@ -0,0 +1,431 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
)
|
||||
|
||||
// Item interface that all list items must implement
|
||||
type Item interface {
|
||||
Render(selected bool, width int, baseStyle styles.Style) string
|
||||
Selectable() bool
|
||||
}
|
||||
|
||||
// RenderFunc defines how to render an item in the list
|
||||
type RenderFunc[T any] func(item T, selected bool, width int, baseStyle styles.Style) string
|
||||
|
||||
// SelectableFunc defines whether an item is selectable
|
||||
type SelectableFunc[T any] func(item T) bool
|
||||
|
||||
// Options holds configuration for the list component
|
||||
type Options[T any] struct {
|
||||
items []T
|
||||
maxVisibleHeight int
|
||||
fallbackMsg string
|
||||
useAlphaNumericKeys bool
|
||||
renderItem RenderFunc[T]
|
||||
isSelectable SelectableFunc[T]
|
||||
baseStyle styles.Style
|
||||
}
|
||||
|
||||
// Option is a function that configures the list component
|
||||
type Option[T any] func(*Options[T])
|
||||
|
||||
// WithItems sets the initial items for the list
|
||||
func WithItems[T any](items []T) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.items = items
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxVisibleHeight sets the maximum visible height in lines
|
||||
func WithMaxVisibleHeight[T any](height int) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.maxVisibleHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
// WithFallbackMessage sets the message to show when the list is empty
|
||||
func WithFallbackMessage[T any](msg string) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.fallbackMsg = msg
|
||||
}
|
||||
}
|
||||
|
||||
// WithAlphaNumericKeys enables j/k navigation keys
|
||||
func WithAlphaNumericKeys[T any](enabled bool) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.useAlphaNumericKeys = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// WithRenderFunc sets the function to render items
|
||||
func WithRenderFunc[T any](fn RenderFunc[T]) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.renderItem = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithSelectableFunc sets the function to determine if items are selectable
|
||||
func WithSelectableFunc[T any](fn SelectableFunc[T]) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.isSelectable = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithStyle sets the base style that gets passed to render functions
|
||||
func WithStyle[T any](style styles.Style) Option[T] {
|
||||
return func(o *Options[T]) {
|
||||
o.baseStyle = style
|
||||
}
|
||||
}
|
||||
|
||||
type List[T any] interface {
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetMaxWidth(maxWidth int)
|
||||
GetSelectedItem() (item T, idx int)
|
||||
SetItems(items []T)
|
||||
GetItems() []T
|
||||
SetSelectedIndex(idx int)
|
||||
SetEmptyMessage(msg string)
|
||||
IsEmpty() bool
|
||||
GetMaxVisibleHeight() int
|
||||
}
|
||||
|
||||
type listComponent[T any] struct {
|
||||
fallbackMsg string
|
||||
items []T
|
||||
selectedIdx int
|
||||
maxWidth int
|
||||
maxVisibleHeight int
|
||||
useAlphaNumericKeys bool
|
||||
width int
|
||||
height int
|
||||
renderItem RenderFunc[T]
|
||||
isSelectable SelectableFunc[T]
|
||||
baseStyle styles.Style
|
||||
}
|
||||
|
||||
type listKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
UpAlpha key.Binding
|
||||
DownAlpha key.Binding
|
||||
}
|
||||
|
||||
var simpleListKeys = listKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "ctrl+p"),
|
||||
key.WithHelp("↑", "previous list item"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "ctrl+n"),
|
||||
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 *listComponent[T]) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
|
||||
c.moveUp()
|
||||
return c, nil
|
||||
case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
|
||||
c.moveDown()
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// moveUp moves the selection up, skipping non-selectable items
|
||||
func (c *listComponent[T]) moveUp() {
|
||||
if len(c.items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the previous selectable item
|
||||
for i := c.selectedIdx - 1; i >= 0; i-- {
|
||||
if c.isSelectable(c.items[i]) {
|
||||
c.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no selectable item found above, stay at current position
|
||||
}
|
||||
|
||||
// moveDown moves the selection down, skipping non-selectable items
|
||||
func (c *listComponent[T]) moveDown() {
|
||||
if len(c.items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
originalIdx := c.selectedIdx
|
||||
for {
|
||||
if c.selectedIdx < len(c.items)-1 {
|
||||
c.selectedIdx++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if c.isSelectable(c.items[c.selectedIdx]) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent infinite loop
|
||||
if c.selectedIdx == originalIdx {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) GetSelectedItem() (T, int) {
|
||||
if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
|
||||
return c.items[c.selectedIdx], c.selectedIdx
|
||||
}
|
||||
|
||||
var zero T
|
||||
return zero, -1
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetItems(items []T) {
|
||||
c.items = items
|
||||
c.selectedIdx = 0
|
||||
|
||||
// Ensure initial selection is on a selectable item
|
||||
if len(items) > 0 && !c.isSelectable(items[0]) {
|
||||
c.moveDown()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) GetItems() []T {
|
||||
return c.items
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetEmptyMessage(msg string) {
|
||||
c.fallbackMsg = msg
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) IsEmpty() bool {
|
||||
return len(c.items) == 0
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetMaxWidth(width int) {
|
||||
c.maxWidth = width
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) SetSelectedIndex(idx int) {
|
||||
if idx >= 0 && idx < len(c.items) {
|
||||
c.selectedIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) GetMaxVisibleHeight() int {
|
||||
return c.maxVisibleHeight
|
||||
}
|
||||
|
||||
func (c *listComponent[T]) View() string {
|
||||
items := c.items
|
||||
maxWidth := c.maxWidth
|
||||
if maxWidth == 0 {
|
||||
maxWidth = 80 // Default width if not set
|
||||
}
|
||||
|
||||
if len(items) <= 0 {
|
||||
return c.fallbackMsg
|
||||
}
|
||||
|
||||
// Calculate viewport based on actual heights
|
||||
startIdx, endIdx := c.calculateViewport()
|
||||
|
||||
listItems := make([]string, 0, endIdx-startIdx)
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
item := items[i]
|
||||
|
||||
// Special handling for HeaderItem to remove top margin on first item
|
||||
if i == startIdx {
|
||||
// Check if this is a HeaderItem
|
||||
if _, ok := any(item).(Item); ok {
|
||||
if headerItem, isHeader := any(item).(HeaderItem); isHeader {
|
||||
// Render header without top margin when it's first
|
||||
t := theme.CurrentTheme()
|
||||
truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...")
|
||||
headerStyle := c.baseStyle.
|
||||
Foreground(t.Accent()).
|
||||
Bold(true).
|
||||
MarginBottom(0).
|
||||
PaddingLeft(1)
|
||||
listItems = append(listItems, headerStyle.Render(truncatedStr))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle)
|
||||
listItems = append(listItems, title)
|
||||
}
|
||||
|
||||
return strings.Join(listItems, "\n")
|
||||
}
|
||||
|
||||
// calculateViewport determines which items to show based on available space
|
||||
func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
|
||||
items := c.items
|
||||
if len(items) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Calculate heights of all items
|
||||
itemHeights := make([]int, len(items))
|
||||
for i, item := range items {
|
||||
rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle)
|
||||
itemHeights[i] = lipgloss.Height(rendered)
|
||||
}
|
||||
|
||||
// Find the range of items that fit within maxVisibleHeight
|
||||
// Start by trying to center the selected item
|
||||
start := 0
|
||||
end := len(items)
|
||||
|
||||
// Calculate height from start to selected
|
||||
heightToSelected := 0
|
||||
for i := 0; i <= c.selectedIdx && i < len(items); i++ {
|
||||
heightToSelected += itemHeights[i]
|
||||
}
|
||||
|
||||
// If selected item is beyond visible height, scroll to show it
|
||||
if heightToSelected > c.maxVisibleHeight {
|
||||
// Start from selected and work backwards to find start
|
||||
currentHeight := itemHeights[c.selectedIdx]
|
||||
start = c.selectedIdx
|
||||
|
||||
for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
|
||||
currentHeight += itemHeights[i]
|
||||
start = i
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate end based on start
|
||||
currentHeight := 0
|
||||
for i := start; i < len(items); i++ {
|
||||
if currentHeight+itemHeights[i] > c.maxVisibleHeight {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
currentHeight += itemHeights[i]
|
||||
}
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func NewListComponent[T any](opts ...Option[T]) List[T] {
|
||||
options := &Options[T]{
|
||||
baseStyle: styles.NewStyle(), // Default empty style
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
return &listComponent[T]{
|
||||
fallbackMsg: options.fallbackMsg,
|
||||
items: options.items,
|
||||
maxVisibleHeight: options.maxVisibleHeight,
|
||||
useAlphaNumericKeys: options.useAlphaNumericKeys,
|
||||
selectedIdx: 0,
|
||||
renderItem: options.renderItem,
|
||||
isSelectable: options.isSelectable,
|
||||
baseStyle: options.baseStyle,
|
||||
}
|
||||
}
|
||||
|
||||
// StringItem is a simple implementation of Item for string values
|
||||
type StringItem string
|
||||
|
||||
func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
|
||||
|
||||
var itemStyle styles.Style
|
||||
if selected {
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingLeft(1)
|
||||
} else {
|
||||
itemStyle = baseStyle.
|
||||
Foreground(t.TextMuted()).
|
||||
PaddingLeft(1)
|
||||
}
|
||||
|
||||
return itemStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (s StringItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// HeaderItem is a non-selectable header item for grouping
|
||||
type HeaderItem string
|
||||
|
||||
func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
|
||||
|
||||
headerStyle := baseStyle.
|
||||
Foreground(t.Accent()).
|
||||
Bold(true).
|
||||
MarginTop(1).
|
||||
MarginBottom(0).
|
||||
PaddingLeft(1)
|
||||
|
||||
return headerStyle.Render(truncatedStr)
|
||||
}
|
||||
|
||||
func (h HeaderItem) Selectable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Ensure StringItem and HeaderItem implement Item
|
||||
var _ Item = StringItem("")
|
||||
var _ Item = HeaderItem("")
|
||||
@@ -0,0 +1,210 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
)
|
||||
|
||||
// testItem is a simple test implementation of ListItem
|
||||
type testItem struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (t testItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
isFirstInViewport bool,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
return t.value
|
||||
}
|
||||
|
||||
func (t testItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// createTestList creates a list with test items for testing
|
||||
func createTestList() *listComponent[testItem] {
|
||||
items := []testItem{
|
||||
{value: "item1"},
|
||||
{value: "item2"},
|
||||
{value: "item3"},
|
||||
}
|
||||
list := NewListComponent(
|
||||
WithItems(items),
|
||||
WithMaxVisibleHeight[testItem](5),
|
||||
WithFallbackMessage[testItem]("empty"),
|
||||
WithAlphaNumericKeys[testItem](false),
|
||||
WithRenderFunc(
|
||||
func(item testItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, false, baseStyle)
|
||||
},
|
||||
),
|
||||
WithSelectableFunc(func(item testItem) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
return list.(*listComponent[testItem])
|
||||
}
|
||||
|
||||
func TestArrowKeyNavigation(t *testing.T) {
|
||||
list := createTestList()
|
||||
|
||||
// Test down arrow navigation
|
||||
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
updatedModel, _ := list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 1 {
|
||||
t.Errorf("Expected selected index 1 after down arrow, got %d", idx)
|
||||
}
|
||||
|
||||
// Test up arrow navigation
|
||||
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
updatedModel, _ = list.Update(upKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected selected index 0 after up arrow, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJKKeyNavigation(t *testing.T) {
|
||||
items := []testItem{
|
||||
{value: "item1"},
|
||||
{value: "item2"},
|
||||
{value: "item3"},
|
||||
}
|
||||
// Create list with alpha keys enabled
|
||||
list := NewListComponent(
|
||||
WithItems(items),
|
||||
WithMaxVisibleHeight[testItem](5),
|
||||
WithFallbackMessage[testItem]("empty"),
|
||||
WithAlphaNumericKeys[testItem](true),
|
||||
WithRenderFunc(
|
||||
func(item testItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, false, baseStyle)
|
||||
},
|
||||
),
|
||||
WithSelectableFunc(func(item testItem) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
// Test j key (down)
|
||||
jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
|
||||
updatedModel, _ := list.Update(jKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 1 {
|
||||
t.Errorf("Expected selected index 1 after 'j' key, got %d", idx)
|
||||
}
|
||||
|
||||
// Test k key (up)
|
||||
kKey := tea.KeyPressMsg{Code: 'k', Text: "k"}
|
||||
updatedModel, _ = list.Update(kKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected selected index 0 after 'k' key, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlNavigation(t *testing.T) {
|
||||
list := createTestList()
|
||||
|
||||
// Test Ctrl-N (down)
|
||||
ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
|
||||
updatedModel, _ := list.Update(ctrlN)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 1 {
|
||||
t.Errorf("Expected selected index 1 after Ctrl-N, got %d", idx)
|
||||
}
|
||||
|
||||
// Test Ctrl-P (up)
|
||||
ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
|
||||
updatedModel, _ = list.Update(ctrlP)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected selected index 0 after Ctrl-P, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationBoundaries(t *testing.T) {
|
||||
list := createTestList()
|
||||
|
||||
// Test up arrow at first item (should stay at 0)
|
||||
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
updatedModel, _ := list.Update(upKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx := list.GetSelectedItem()
|
||||
if idx != 0 {
|
||||
t.Errorf("Expected to stay at index 0 when pressing up at first item, got %d", idx)
|
||||
}
|
||||
|
||||
// Move to last item
|
||||
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
updatedModel, _ = list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 2 {
|
||||
t.Errorf("Expected to be at index 2, got %d", idx)
|
||||
}
|
||||
|
||||
// Test down arrow at last item (should stay at 2)
|
||||
updatedModel, _ = list.Update(downKey)
|
||||
list = updatedModel.(*listComponent[testItem])
|
||||
_, idx = list.GetSelectedItem()
|
||||
if idx != 2 {
|
||||
t.Errorf("Expected to stay at index 2 when pressing down at last item, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyList(t *testing.T) {
|
||||
emptyList := NewListComponent(
|
||||
WithItems([]testItem{}),
|
||||
WithMaxVisibleHeight[testItem](5),
|
||||
WithFallbackMessage[testItem]("empty"),
|
||||
WithAlphaNumericKeys[testItem](false),
|
||||
WithRenderFunc(
|
||||
func(item testItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
return item.Render(selected, width, false, baseStyle)
|
||||
},
|
||||
),
|
||||
WithSelectableFunc(func(item testItem) bool {
|
||||
return item.Selectable()
|
||||
}),
|
||||
)
|
||||
|
||||
// Test navigation on empty list (should not crash)
|
||||
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
|
||||
ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
|
||||
|
||||
updatedModel, _ := emptyList.Update(downKey)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = emptyList.Update(upKey)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = emptyList.Update(ctrlN)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
updatedModel, _ = emptyList.Update(ctrlP)
|
||||
emptyList = updatedModel.(*listComponent[testItem])
|
||||
|
||||
// Verify empty list behavior
|
||||
_, idx := emptyList.GetSelectedItem()
|
||||
if idx != -1 {
|
||||
t.Errorf("Expected index -1 for empty list, got %d", idx)
|
||||
}
|
||||
|
||||
if !emptyList.IsEmpty() {
|
||||
t.Error("Expected IsEmpty() to return true for empty list")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -95,7 +95,7 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||
var finalContent string
|
||||
if m.title != "" {
|
||||
titleStyle := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Foreground(t.Text()).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
@@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string {
|
||||
modalView,
|
||||
background,
|
||||
layout.WithOverlayBorder(),
|
||||
layout.WithOverlayBorderColor(t.Primary()),
|
||||
layout.WithOverlayBorderColor(t.BorderActive()),
|
||||
)
|
||||
}
|
||||
+57
-41
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
rw "github.com/mattn/go-runewidth"
|
||||
"github.com/rivo/uniseg"
|
||||
"github.com/sst/opencode/internal/attachment"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,15 +33,6 @@ const (
|
||||
maxLines = 10000
|
||||
)
|
||||
|
||||
// Attachment represents a special object within the text, distinct from regular characters.
|
||||
type Attachment struct {
|
||||
ID string // A unique identifier for this attachment instance
|
||||
Display string // e.g., "@filename.txt"
|
||||
URL string
|
||||
Filename string
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// Helper functions for converting between runes and any slices
|
||||
|
||||
// runesToInterfaces converts a slice of runes to a slice of interfaces
|
||||
@@ -59,7 +51,7 @@ func interfacesToRunes(items []any) []rune {
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
result = append(result, val)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
result = append(result, []rune(val.Display)...)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +72,7 @@ func interfacesToString(items []any) string {
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
s.WriteRune(val)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
s.WriteString(val.Display)
|
||||
}
|
||||
}
|
||||
@@ -90,7 +82,7 @@ func interfacesToString(items []any) string {
|
||||
// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment.
|
||||
// This allows for proper highlighting even when the cursor is technically at the position
|
||||
// after the attachment object in the underlying slice.
|
||||
func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
|
||||
func (m Model) isAttachmentAtCursor() (*attachment.Attachment, int, int) {
|
||||
if m.row >= len(m.value) {
|
||||
return nil, -1, -1
|
||||
}
|
||||
@@ -104,7 +96,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
|
||||
|
||||
// Check if the cursor is at the same index as an attachment.
|
||||
if col < len(row) {
|
||||
if att, ok := row[col].(*Attachment); ok {
|
||||
if att, ok := row[col].(*attachment.Attachment); ok {
|
||||
return att, col, col
|
||||
}
|
||||
}
|
||||
@@ -112,7 +104,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
|
||||
// Check if the cursor is immediately after an attachment. This is a common
|
||||
// state, for example, after just inserting one.
|
||||
if col > 0 && col <= len(row) {
|
||||
if att, ok := row[col-1].(*Attachment); ok {
|
||||
if att, ok := row[col-1].(*attachment.Attachment); ok {
|
||||
return att, col - 1, col - 1
|
||||
}
|
||||
}
|
||||
@@ -132,7 +124,7 @@ func (m Model) renderLineWithAttachments(
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
s.WriteString(style.Render(string(val)))
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
// Check if this is the attachment the cursor is currently on
|
||||
if currentAttachment != nil && currentAttachment.ID == val.ID {
|
||||
// Cursor is on this attachment, highlight it
|
||||
@@ -218,11 +210,11 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithHelp("left", "character backward"),
|
||||
),
|
||||
WordForward: key.NewBinding(
|
||||
key.WithKeys("alt+right", "alt+f"),
|
||||
key.WithKeys("alt+right", "ctrl+right", "alt+f"),
|
||||
key.WithHelp("alt+right", "word forward"),
|
||||
),
|
||||
WordBackward: key.NewBinding(
|
||||
key.WithKeys("alt+left", "alt+b"),
|
||||
key.WithKeys("alt+left", "ctrl+left", "alt+b"),
|
||||
key.WithHelp("alt+left", "word backward"),
|
||||
),
|
||||
LineNext: key.NewBinding(
|
||||
@@ -435,7 +427,7 @@ func (w line) Hash() string {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
s.WriteRune(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
s.WriteString(v.ID)
|
||||
}
|
||||
}
|
||||
@@ -661,7 +653,7 @@ func (m *Model) InsertRune(r rune) {
|
||||
}
|
||||
|
||||
// InsertAttachment inserts an attachment at the cursor position.
|
||||
func (m *Model) InsertAttachment(att *Attachment) {
|
||||
func (m *Model) InsertAttachment(att *attachment.Attachment) {
|
||||
if m.CharLimit > 0 {
|
||||
availSpace := m.CharLimit - m.Length()
|
||||
// If the char limit's been reached, cancel.
|
||||
@@ -716,16 +708,36 @@ func (m *Model) CurrentRowLength() int {
|
||||
return len(m.value[m.row])
|
||||
}
|
||||
|
||||
// GetAttachments returns all attachments in the textarea.
|
||||
func (m Model) GetAttachments() []*Attachment {
|
||||
var attachments []*Attachment
|
||||
for _, row := range m.value {
|
||||
// GetAttachments returns all attachments in the textarea with accurate position indices.
|
||||
func (m Model) GetAttachments() []*attachment.Attachment {
|
||||
var attachments []*attachment.Attachment
|
||||
position := 0 // Track absolute position in the text
|
||||
|
||||
for rowIdx, row := range m.value {
|
||||
colPosition := 0 // Track position within the current row
|
||||
|
||||
for _, item := range row {
|
||||
if att, ok := item.(*Attachment); ok {
|
||||
attachments = append(attachments, att)
|
||||
switch v := item.(type) {
|
||||
case *attachment.Attachment:
|
||||
// Clone the attachment to avoid modifying the original
|
||||
att := *v
|
||||
att.StartIndex = position + colPosition
|
||||
att.EndIndex = position + colPosition + len(v.Display)
|
||||
attachments = append(attachments, &att)
|
||||
colPosition += len(v.Display)
|
||||
case rune:
|
||||
colPosition++
|
||||
}
|
||||
}
|
||||
|
||||
// Add newline character position (except for last row)
|
||||
if rowIdx < len(m.value)-1 {
|
||||
position += colPosition + 1 // +1 for newline
|
||||
} else {
|
||||
position += colPosition
|
||||
}
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
@@ -829,7 +841,7 @@ func (m Model) Value() string {
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
v.WriteRune(val)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
v.WriteString(val.Display)
|
||||
}
|
||||
}
|
||||
@@ -847,7 +859,7 @@ func (m *Model) Length() int {
|
||||
switch val := item.(type) {
|
||||
case rune:
|
||||
l += rw.RuneWidth(val)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
l += uniseg.StringWidth(val.Display)
|
||||
}
|
||||
}
|
||||
@@ -911,7 +923,7 @@ func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
|
||||
@@ -952,7 +964,7 @@ func (m *Model) CursorDown() {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
@@ -988,7 +1000,7 @@ func (m *Model) CursorDown() {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
@@ -1034,7 +1046,7 @@ func (m *Model) CursorUp() {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
@@ -1070,7 +1082,7 @@ func (m *Model) CursorUp() {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
itemWidth = rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
itemWidth = uniseg.StringWidth(v.Display)
|
||||
}
|
||||
if offset+itemWidth > charOffset {
|
||||
@@ -1111,6 +1123,10 @@ func (m *Model) CursorEnd() {
|
||||
m.SetCursorColumn(len(m.value[m.row]))
|
||||
}
|
||||
|
||||
func (m *Model) IsCursorAtEnd() bool {
|
||||
return m.CursorColumn() == len(m.value[m.row])
|
||||
}
|
||||
|
||||
// Focused returns the focus state on the model.
|
||||
func (m Model) Focused() bool {
|
||||
return m.focus
|
||||
@@ -1414,14 +1430,14 @@ func (m Model) Width() int {
|
||||
return m.width
|
||||
}
|
||||
|
||||
// moveToBegin moves the cursor to the beginning of the input.
|
||||
func (m *Model) moveToBegin() {
|
||||
// MoveToBegin moves the cursor to the beginning of the input.
|
||||
func (m *Model) MoveToBegin() {
|
||||
m.row = 0
|
||||
m.SetCursorColumn(0)
|
||||
}
|
||||
|
||||
// moveToEnd moves the cursor to the end of the input.
|
||||
func (m *Model) moveToEnd() {
|
||||
// MoveToEnd moves the cursor to the end of the input.
|
||||
func (m *Model) MoveToEnd() {
|
||||
m.row = len(m.value) - 1
|
||||
m.SetCursorColumn(len(m.value[m.row]))
|
||||
}
|
||||
@@ -1610,9 +1626,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
case key.Matches(msg, m.KeyMap.WordBackward):
|
||||
m.wordLeft()
|
||||
case key.Matches(msg, m.KeyMap.InputBegin):
|
||||
m.moveToBegin()
|
||||
m.MoveToBegin()
|
||||
case key.Matches(msg, m.KeyMap.InputEnd):
|
||||
m.moveToEnd()
|
||||
m.MoveToEnd()
|
||||
case key.Matches(msg, m.KeyMap.LowercaseWordForward):
|
||||
m.lowercaseRight()
|
||||
case key.Matches(msg, m.KeyMap.UppercaseWordForward):
|
||||
@@ -1725,7 +1741,7 @@ func (m Model) View() string {
|
||||
} else if lineInfo.ColumnOffset < len(wrappedLine) {
|
||||
// Render the item under the cursor
|
||||
item := wrappedLine[lineInfo.ColumnOffset]
|
||||
if att, ok := item.(*Attachment); ok {
|
||||
if att, ok := item.(*attachment.Attachment); ok {
|
||||
// Item at cursor is an attachment. Render it with the selection style.
|
||||
// This becomes the "cursor" visually.
|
||||
s.WriteString(m.Styles.SelectedAttachment.Render(att.Display))
|
||||
@@ -2023,7 +2039,7 @@ func itemWidth(item any) int {
|
||||
switch v := item.(type) {
|
||||
case rune:
|
||||
return rw.RuneWidth(v)
|
||||
case *Attachment:
|
||||
case *attachment.Attachment:
|
||||
return uniseg.StringWidth(v.Display)
|
||||
}
|
||||
return 0
|
||||
@@ -2052,7 +2068,7 @@ func wrapInterfaces(content []any, width int) [][]any {
|
||||
isSpace = true
|
||||
}
|
||||
itemW = rw.RuneWidth(r)
|
||||
} else if att, ok := item.(*Attachment); ok {
|
||||
} else if att, ok := item.(*attachment.Attachment); ok {
|
||||
itemW = uniseg.StringWidth(att.Display)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package id
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PrefixSession = "ses"
|
||||
PrefixMessage = "msg"
|
||||
PrefixUser = "usr"
|
||||
PrefixPart = "prt"
|
||||
)
|
||||
|
||||
const length = 26
|
||||
|
||||
var (
|
||||
lastTimestamp int64
|
||||
counter int64
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
type Prefix string
|
||||
|
||||
const (
|
||||
Session Prefix = PrefixSession
|
||||
Message Prefix = PrefixMessage
|
||||
User Prefix = PrefixUser
|
||||
Part Prefix = PrefixPart
|
||||
)
|
||||
|
||||
func ValidatePrefix(id string, prefix Prefix) bool {
|
||||
return strings.HasPrefix(id, string(prefix))
|
||||
}
|
||||
|
||||
func Ascending(prefix Prefix, given ...string) string {
|
||||
return generateID(prefix, false, given...)
|
||||
}
|
||||
|
||||
func Descending(prefix Prefix, given ...string) string {
|
||||
return generateID(prefix, true, given...)
|
||||
}
|
||||
|
||||
func generateID(prefix Prefix, descending bool, given ...string) string {
|
||||
if len(given) > 0 && given[0] != "" {
|
||||
if !strings.HasPrefix(given[0], string(prefix)) {
|
||||
panic(fmt.Sprintf("ID %s does not start with %s", given[0], string(prefix)))
|
||||
}
|
||||
return given[0]
|
||||
}
|
||||
|
||||
return generateNewID(prefix, descending)
|
||||
}
|
||||
|
||||
func randomBase62(length int) string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
result := make([]byte, length)
|
||||
bytes := make([]byte, length)
|
||||
rand.Read(bytes)
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
result[i] = chars[bytes[i]%62]
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func generateNewID(prefix Prefix, descending bool) string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
currentTimestamp := time.Now().UnixMilli()
|
||||
|
||||
if currentTimestamp != lastTimestamp {
|
||||
lastTimestamp = currentTimestamp
|
||||
counter = 0
|
||||
}
|
||||
counter++
|
||||
|
||||
now := uint64(currentTimestamp)*0x1000 + uint64(counter)
|
||||
|
||||
if descending {
|
||||
now = ^now
|
||||
}
|
||||
|
||||
timeBytes := make([]byte, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
timeBytes[i] = byte((now >> (40 - 8*i)) & 0xff)
|
||||
}
|
||||
|
||||
return string(prefix) + "_" + hex.EncodeToString(timeBytes) + randomBase62(length-12)
|
||||
}
|
||||
+5
-3
@@ -15,6 +15,11 @@ import (
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
var (
|
||||
// ANSI escape sequence regex
|
||||
ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
)
|
||||
|
||||
// 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")
|
||||
@@ -272,9 +277,6 @@ func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
|
||||
|
||||
// getStyleAtPosition extracts the active ANSI style at a given visual position
|
||||
func getStyleAtPosition(s string, targetPos int) ansiStyle {
|
||||
// ANSI escape sequence regex
|
||||
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
|
||||
visualPos := 0
|
||||
currentStyle := ansiStyle{}
|
||||
|
||||
-1
@@ -284,7 +284,6 @@ func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.Styl
|
||||
Table: ansi.StyleTable{
|
||||
StyleBlock: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
BlockPrefix: "\n",
|
||||
BlockSuffix: "\n",
|
||||
},
|
||||
},
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user