Compare commits

..
17 Commits
Author SHA1 Message Date
Dax Raad ffc231bd8b docs: contributing
publish / publish (push) Has been cancelled
2025-06-14 12:45:26 -04:00
Dax Raad 3cf4ef56fb sync 2025-06-14 12:32:41 -04:00
Dax Raad c738e26438 docs: mcp 2025-06-14 12:25:26 -04:00
Dax Raad 9c6aa82ac1 docs: config schema 2025-06-14 12:22:07 -04:00
Dax Raad ef74d97491 ci: update publish script 2025-06-14 12:13:59 -04:00
Dax Raad af892e5432 docs: readme 2025-06-14 12:13:46 -04:00
Dax Raad d7aca6230d naming fixes
publish / publish (push) Has been cancelled
2025-06-14 01:54:28 -04:00
Dax RaadandOpenCode 0f9c2c5c27 Add flag system and auto-share functionality
publish / publish (push) Has been cancelled
- Add Flag module for environment variable configuration
- Implement OPENCODE_AUTO_SHARE flag to automatically share new sessions
- Update session creation to conditionally auto-share based on flag

🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-14 01:51:04 -04:00
Dax RaadandOpenCode 6a261dedb4 Improve logging and simplify fzf implementation
- Refactor fzf search to use Bun's $ syntax for cleaner command execution
- Add request/response duration logging to server middleware
- Set default service name for logging to improve log clarity

🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-14 01:51:04 -04:00
Alireza BahramiandGitHub ec928d88b5 fix(install): check if the path export command already exists (#28) 2025-06-13 23:28:33 -04:00
Dax RaadandOpenCode 59a5f120c0 Clean up workflows and enhance file discovery tools to include dot files
publish / publish (push) Has been cancelled
🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <noreply@opencode.ai>
2025-06-13 23:24:46 -04:00
Dax Raad ce07f80b19 sync 2025-06-13 17:42:56 -04:00
Dax Raad 168fd9b2e3 screenshot 2025-06-13 17:42:14 -04:00
Dax Raad df13b155f9 disable autoshare
publish / publish (push) Has been cancelled
2025-06-13 17:30:17 -04:00
Dax Raad eeed5b8718 sync 2025-06-13 17:24:45 -04:00
Dax Raad 148ef90210 sync
publish / publish (push) Has been cancelled
2025-06-13 17:23:22 -04:00
adamdottv 67023bb007 wip: refactoring tui 2025-06-13 15:56:33 -05:00
36 changed files with 591 additions and 217 deletions
-37
View File
@@ -1,37 +0,0 @@
name: build
on:
workflow_dispatch:
push:
branches:
- dev
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v5
with:
go-version: ">=1.23.2"
cache: true
cache-dependency-path: go.sum
- run: go mod download
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: build --snapshot --clean
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- dontlook
- dev
tags:
- "*"
+74 -22
View File
@@ -1,40 +1,92 @@
```
█▀▀█ █▀▀█ █▀▀ █▀▀▄ █▀▀ █▀▀█ █▀▀▄ █▀▀
█░░█ █░░█ █▀▀ █░░█ █░░ █░░█ █░░█ █▀▀
▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀
```
[![OpenCode Terminal UI](screenshot.png)](https://github.com/sst/opencode)
AI coding agent, built for the terminal.
Note: version 0.1.x is a full rewrite and we do not have proper documentation for it yet. Should have this out week of June 17th 2025
⚠️ **Note:** version 0.1.x is a full rewrite and we do not have proper documentation for it yet. Should have this out week of June 17th 2025 📚
## Installation
### Installation
### Curl
```
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Package managers
npm i -g opencode-ai@latest # or bun/pnpm/yarn
brew install sst/tap/opencode # macOS
paru -S opencode-bin # Arch Linux
```
### NPM
> **Note:** Remove previous versions < 0.1.x first if installed
```
npm i -g opencode-ai@latest
bun i -g opencode-ai@latest
pnpm i -g opencode-ai@latest
yarn global add opencode-ai@latest
### Providers
The recommended approach is to sign up for claude pro or max and do `opencode auth login` and select Anthropic. It is the most cost effective way to use this tool.
Additionally opencode is powered by the provider list at [models.dev](https://models.dev) so you can use `opencode auth login` to configure api keys for any provider you'd like to use. This is stored in `~/.local/share/opencode/auth.json`
```bash
$ opencode auth login
┌ Add credential
◆ Select provider
│ ● Anthropic (recommended)
│ ○ OpenAI
│ ○ Google
│ ○ Amazon Bedrock
│ ○ Azure
│ ○ DeepSeek
│ ○ Groq
│ ...
```
### Brew
The models.dev dataset is also used to detect common environment variables like `OPENAI_API_KEY` to autoload that provider.
```
brew install sst/tap/opencode
If there are additional providers you want to use you can submit a PR to the [models.dev repo](https://github.com/sst/models.dev). If configuring just for yourself check out the Config section below
### Project Config
Project configuration is optional. You can place an `opencode.json` file in the root of your repo and it will be loaded.
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json"
}
```
### AUR
#### MCP
```json title="opencode.json"
{
"$schema": "http://opencode.ai/config.json",
"mcp": {
"localmcp": {
"type": "local",
"command": ["bun", "x", "my-mcp-command"],
"environment": {
"MY_ENV_VAR": "my_env_var_value"
}
},
"remotemcp": {
"type": "remote",
"url": "https://my-mcp-server.com"
}
}
}
```
### Contributing
To run opencode locally you need
- bun
- golang 1.24.x
To run
```
paru -S opencode-bin
$ bun install
$ cd packages/opencode
$ bun run src/index.ts
```
+1
View File
@@ -50,6 +50,7 @@
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"typescript": "catalog:",
"zod-to-json-schema": "3.24.5",
},
},
"packages/web": {
+4 -1
View File
@@ -107,7 +107,9 @@ add_to_path() {
local config_file=$1
local command=$2
if [[ -w $config_file ]]; then
if grep -Fxq "$command" "$config_file"; then
print_message info "Command already exists in $config_file, skipping write."
elif [[ -w $config_file ]]; then
echo -e "\n# opencode" >> "$config_file"
echo "$command" >> "$config_file"
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
@@ -173,6 +175,7 @@ if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
*)
export PATH=$INSTALL_DIR:$PATH
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " export PATH=$INSTALL_DIR:\$PATH"
;;
+152
View File
@@ -0,0 +1,152 @@
{
"type": "object",
"properties": {
"$schema": {
"type": "string"
},
"provider": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"models": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"inputCached": {
"type": "number"
},
"outputCached": {
"type": "number"
}
},
"required": [
"input",
"output",
"inputCached",
"outputCached"
],
"additionalProperties": false
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": [
"context",
"output"
],
"additionalProperties": false
},
"id": {
"type": "string"
}
},
"additionalProperties": false
}
},
"options": {
"type": "object",
"additionalProperties": {}
}
},
"required": [
"models"
],
"additionalProperties": false
}
},
"mcp": {
"type": "object",
"additionalProperties": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "local"
},
"command": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
"type",
"command"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "remote"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"additionalProperties": false
}
]
}
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
+4 -2
View File
@@ -5,7 +5,8 @@
"type": "module",
"private": true,
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"dev": "bun run ./src/index.ts"
},
"exports": {
"./*": [
@@ -18,7 +19,8 @@
"@types/bun": "latest",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"typescript": "catalog:"
"typescript": "catalog:",
"zod-to-json-schema": "3.24.5"
},
"dependencies": {
"@clack/prompts": "0.11.0",
+2 -1
View File
@@ -110,7 +110,8 @@ if (!snapshot) {
return (
!lower.includes("chore:") &&
!lower.includes("ci:") &&
!lower.includes("docs:")
!lower.includes("docs:") &&
!lower.includes("doc:")
)
})
.join("\n")
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bun
import "zod-openapi/extend"
import { Config } from "../src/config/config"
import { zodToJsonSchema } from "zod-to-json-schema"
const result = zodToJsonSchema(Config.Info)
await Bun.write("config.schema.json", JSON.stringify(result, null, 2))
@@ -2,6 +2,7 @@ import { Server } from "../../server/server"
import fs from "fs/promises"
import path from "path"
import type { CommandModule } from "yargs"
import { Config } from "../../config/config"
export const GenerateCommand = {
command: "generate",
+1 -5
View File
@@ -50,6 +50,7 @@ export namespace Config {
export const Info = z
.object({
$schema: z.string().optional(),
provider: z
.record(
ModelsDev.Provider.partial().extend({
@@ -58,11 +59,6 @@ export namespace Config {
}),
)
.optional(),
tool: z
.object({
provider: z.record(z.string(), z.string().array()).optional(),
})
.optional(),
mcp: z.record(z.string(), Mcp).optional(),
})
.strict()
+11 -11
View File
@@ -1,4 +1,3 @@
import { App } from "../app/app"
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
@@ -6,6 +5,7 @@ import { z } from "zod"
import { NamedError } from "../util/error"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { $ } from "bun"
export namespace Fzf {
const log = Log.create({ service: "fzf" })
@@ -117,18 +117,18 @@ export namespace Fzf {
}
export async function search(cwd: string, query: string) {
const process = Bun.spawn({
cwd,
stdin: "inherit",
stdout: "pipe",
stderr: "pipe",
cmd: [await filepath(), "--filter", query],
})
await process.exited
const stdout = await Bun.readableStreamToText(process.stdout)
return stdout
const results = await $`${await filepath()} --filter ${query}`
.quiet()
.throws(false)
.cwd(cwd)
.text()
const split = results
.trim()
.split("\n")
.filter((line) => line.length > 0)
log.info("results", {
count: split.length,
})
return split
}
}
+8
View File
@@ -0,0 +1,8 @@
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ const cli = yargs(hideBin(process.argv))
cmd = [binary]
}
const proc = Bun.spawn({
cmd,
cmd: [...cmd, ...process.argv.slice(2)],
cwd,
stdout: "inherit",
stderr: "inherit",
@@ -289,11 +289,13 @@ export namespace Provider {
google: TOOLS,
}
export async function tools(providerID: string) {
/*
const cfg = await Config.get()
if (cfg.tool?.provider?.[providerID])
return cfg.tool.provider[providerID].map(
(id) => TOOLS.find((t) => t.id === id)!,
)
*/
return TOOL_MAPPING[providerID] ?? TOOLS
}
+6 -2
View File
@@ -56,12 +56,16 @@ export namespace Server {
},
)
})
.use((c, next) => {
.use(async (c, next) => {
log.info("request", {
method: c.req.method,
path: c.req.path,
})
return next()
const start = Date.now()
await next()
log.info("response", {
duration: Date.now() - start,
})
})
.get(
"/openapi",
+2 -1
View File
@@ -27,6 +27,7 @@ import { MCP } from "../mcp"
import { NamedError } from "../util/error"
import type { Tool } from "../tool/tool"
import { SystemPrompt } from "./system"
import { Flag } from "../flag/flag"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -92,7 +93,7 @@ export namespace Session {
log.info("created", result)
state().sessions.set(result.id, result)
await Storage.writeJSON("session/info/" + result.id, result)
if (!result.parentID)
if (!result.parentID && Flag.OPENCODE_AUTO_SHARE)
share(result.id).then((share) => {
update(result.id, (draft) => {
draft.share = share
@@ -1,14 +1,14 @@
You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using OpenCode
- /help: Get help with using opencode
- To give feedback, users should report the issue at https://github.com/sst/opencode/issues
When the user directly asks about OpenCode (eg 'can OpenCode do...', 'does OpenCode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from OpenCode docs at https://opencode.ai
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
+6 -6
View File
@@ -22,7 +22,7 @@ Usage notes:
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- VERY IMPORTANT: You MUST avoid using search commands like `find` and `grep`. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like `cat`, `head`, `tail`, and `ls`, and use Read and LS to read files.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all OpenCode users have pre-installed.
- If you _still_ need to run `grep`, STOP. ALWAYS USE ripgrep at `rg` (or /usr/bin/rg) first, which all opencode users have pre-installed.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
<good-example>
@@ -60,9 +60,9 @@ When the user asks you to create a new git commit, follow these steps carefully:
3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel:
- Add relevant untracked files to the staging area.
- Create the commit with a message ending with:
🤖 Generated with [OpenCode](https://opencode.ai)
🤖 Generated with [opencode](https://opencode.ai)
Co-Authored-By: OpenCode <noreply@opencode.ai>
Co-Authored-By: opencode <noreply@opencode.ai>
- Run git status to make sure the commit succeeded.
4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
@@ -81,9 +81,9 @@ Important notes:
git commit -m "$(cat <<'EOF'
Commit message here.
🤖 Generated with [OpenCode](https://opencode.ai)
🤖 Generated with [opencode](https://opencode.ai)
Co-Authored-By: OpenCode <noreply@opencode.ai>
Co-Authored-By: opencode <noreply@opencode.ai>
EOF
)"
</example>
@@ -128,7 +128,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Test plan
[Checklist of TODOs for testing the pull request...]
🤖 Generated with [OpenCode](https://opencode.ai)
🤖 Generated with [opencode](https://opencode.ai)
EOF
)"
</example>
+1 -1
View File
@@ -27,7 +27,7 @@ export const GlobTool = Tool.define({
const glob = new Bun.Glob(params.pattern)
const files = []
let truncated = false
for await (const file of glob.scan({ cwd: search })) {
for await (const file of glob.scan({ cwd: search, dot: true })) {
if (files.length >= limit) {
truncated = true
break
+1 -1
View File
@@ -40,7 +40,7 @@ export const ListTool = Tool.define({
const glob = new Bun.Glob("**/*")
const files = []
for await (const file of glob.scan({ cwd: searchPath })) {
for await (const file of glob.scan({ cwd: searchPath, dot: true })) {
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
continue
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path"
import fs from "fs/promises"
import { Global } from "../global"
export namespace Log {
export const Default = create()
export const Default = create({ service: "default" })
export interface Options {
print: boolean
@@ -0,0 +1,71 @@
package completions
import (
"sort"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
)
type CommandCompletionProvider struct {
app *app.App
}
func NewCommandCompletionProvider(app *app.App) dialog.CompletionProvider {
return &CommandCompletionProvider{app: app}
}
func (c *CommandCompletionProvider) GetId() string {
return "commands"
}
func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI {
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: "Commands",
Value: "commands",
})
}
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
if query == "" {
// If no query, return all commands
items := []dialog.CompletionItemI{}
for _, cmd := range c.app.Commands {
items = append(items, dialog.NewCompletionItem(dialog.CompletionItem{
Title: " /" + cmd.Name,
Value: "/" + cmd.Name,
}))
}
return items, nil
}
// Use fuzzy matching for commands
var commandNames []string
commandMap := make(map[string]dialog.CompletionItemI)
for _, cmd := range c.app.Commands {
commandNames = append(commandNames, cmd.Name)
commandMap[cmd.Name] = dialog.NewCompletionItem(dialog.CompletionItem{
Title: " /" + cmd.Name,
Value: "/" + cmd.Name,
})
}
// Find fuzzy matches
matches := fuzzy.RankFind(query, commandNames)
// Sort by score (best matches first)
sort.Sort(matches)
// Convert matches to completion items
items := []dialog.CompletionItemI{}
for _, match := range matches {
if item, ok := commandMap[match.Target]; ok {
items = append(items, item)
}
}
return items, nil
}
@@ -1,10 +1,15 @@
package completions
import (
"context"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/pkg/client"
)
type filesAndFoldersContextGroup struct {
app *app.App
prefix string
}
@@ -20,7 +25,17 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI {
}
func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) {
return []string{}, nil
response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{
Query: query,
})
if err != nil {
return []string{}, err
}
if response.JSON200 == nil {
return []string{}, nil
}
return *response.JSON200, nil
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
@@ -41,8 +56,9 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C
return items, nil
}
func NewFileAndFolderContextGroup() dialog.CompletionProvider {
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
return &filesAndFoldersContextGroup{
app: app,
prefix: "file",
}
}
@@ -0,0 +1,29 @@
package completions
import (
"strings"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
)
type CompletionManager struct {
providers map[string]dialog.CompletionProvider
}
func NewCompletionManager(app *app.App) *CompletionManager {
return &CompletionManager{
providers: map[string]dialog.CompletionProvider{
"files": NewFileAndFolderContextGroup(app),
"commands": NewCommandCompletionProvider(app),
},
}
}
func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider {
if strings.HasPrefix(input, "/") {
return m.providers["commands"]
}
return m.providers["files"]
}
+23 -15
View File
@@ -44,11 +44,6 @@ type EditorKeyMaps struct {
HistoryDown key.Binding
}
type bluredEditorKeyMaps struct {
Send key.Binding
Focus key.Binding
OpenEditor key.Binding
}
type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding
Escape key.Binding
@@ -108,10 +103,18 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ThemeChangedMsg:
m.textarea = createTextArea(&m.textarea)
case dialog.CompletionSelectedMsg:
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue)
return m, nil
if msg.IsCommand {
// Execute the command directly
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
m.textarea.Reset()
return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
} else {
// For files, replace the text in the editor
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue)
return m, nil
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
@@ -378,12 +381,13 @@ func (m *editorComponent) send() tea.Cmd {
}
// Check for slash command
if strings.HasPrefix(value, "/") {
commandName := strings.TrimPrefix(value, "/")
if _, ok := m.app.Commands[commandName]; ok {
return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
}
}
// if strings.HasPrefix(value, "/") {
// commandName := strings.TrimPrefix(value, "/")
// if _, ok := m.app.Commands[commandName]; ok {
// return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName})
// }
// }
slog.Info("Send message", "value", value)
return tea.Batch(
util.CmdHandler(SendMsg{
@@ -452,6 +456,10 @@ func createTextArea(existing *textarea.Model) textarea.Model {
return ta
}
func (m *editorComponent) GetValue() string {
return m.textarea.Value()
}
func NewEditorComponent(app *app.App) layout.ModelWithView {
s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3)))
ta := createTextArea(nil)
@@ -216,7 +216,7 @@ func renderText(message client.MessageInfo, text string, author string) string {
align = lipgloss.Left
}
textWidth := lipgloss.Width(text)
textWidth := max(lipgloss.Width(text), lipgloss.Width(info))
markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding
content := toMarkdown(text, markdownWidth, t.BackgroundSubtle())
content = lipgloss.JoinVertical(align, content, info)
@@ -299,13 +299,9 @@ func renderToolInvocation(
body := ""
error := ""
finished := result != nil && *result != ""
if finished {
body = *result
}
if e, ok := metadata.Get("error"); ok && e.(bool) == true {
if m, ok := metadata.Get("message"); ok {
body = "" // don't show the body if there's an error
style = style.BorderLeftForeground(t.Error())
error = styles.BaseStyle().
Background(t.BackgroundSubtle()).
@@ -336,58 +332,61 @@ func renderToolInvocation(
case "opencode_read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed)
body = ""
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
filename := toolArgsMap["filePath"].(string)
body = preview.(string)
body = renderFile(filename, body, WithTruncate(6))
}
case "opencode_edit":
filename := toolArgsMap["filePath"].(string)
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
if d, ok := metadata.Get("diff"); ok {
patch := d.(string)
var formattedDiff string
if layout.Current.Viewport.Width < 80 {
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(layout.Current.Container.Width-2),
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed)
if d, ok := metadata.Get("diff"); ok {
patch := d.(string)
var formattedDiff string
if layout.Current.Viewport.Width < 80 {
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(layout.Current.Container.Width-2),
)
} else {
diffWidth := min(layout.Current.Viewport.Width-2, 120)
formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
}
formattedDiff = strings.TrimSpace(formattedDiff)
formattedDiff = lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(t.BackgroundSubtle()).
BorderLeft(true).
BorderRight(true).
Render(formattedDiff)
if showResult {
style = style.Width(lipgloss.Width(formattedDiff))
title += "\n"
}
body = strings.TrimSpace(formattedDiff)
body = lipgloss.Place(
layout.Current.Viewport.Width,
lipgloss.Height(body)+1,
lipgloss.Center,
lipgloss.Top,
body,
)
} else {
diffWidth := min(layout.Current.Viewport.Width-2, 120)
formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth))
}
formattedDiff = strings.TrimSpace(formattedDiff)
formattedDiff = lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(t.BackgroundSubtle()).
BorderLeft(true).
BorderRight(true).
Render(formattedDiff)
if showResult {
style = style.Width(lipgloss.Width(formattedDiff))
title += "\n"
}
body = strings.TrimSpace(formattedDiff)
body = lipgloss.Place(
layout.Current.Viewport.Width,
lipgloss.Height(body)+1,
lipgloss.Center,
lipgloss.Top,
body,
)
}
case "opencode_write":
filename := toolArgsMap["filePath"].(string)
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
content := toolArgsMap["content"].(string)
body = renderFile(filename, content)
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed)
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content)
}
}
case "opencode_bash":
description := toolArgsMap["description"].(string)
title = fmt.Sprintf("Shell: %s %s", description, elapsed)
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("Shell: %s %s", description, elapsed)
}
if stdout, ok := metadata.Get("stdout"); ok {
command := toolArgsMap["command"].(string)
stdout := stdout.(string)
@@ -396,18 +395,20 @@ func renderToolInvocation(
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "opencode_webfetch":
toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed)
format := toolArgsMap["format"].(string)
body = truncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
if format, ok := toolArgsMap["format"].(string); ok {
body = *result
body = truncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
}
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
case "opencode_todowrite":
title = fmt.Sprintf("Planning %s", elapsed)
if to, ok := metadata.Get("todos"); ok && finished {
body = ""
todos := to.([]any)
for _, todo := range todos {
t := todo.(map[string]any)
@@ -416,7 +417,7 @@ func renderToolInvocation(
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
// case "in-progress":
// body += fmt.Sprintf("- [ ] _%s_\n", content)
// body += fmt.Sprintf("- [ ] %s\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
@@ -427,6 +428,13 @@ func renderToolInvocation(
default:
toolName := renderToolName(toolCall.ToolName)
title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed)
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
if body == "" && error == "" {
body = *result
body = truncateHeight(body, 10)
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
@@ -245,7 +245,7 @@ func (m *messagesComponent) header() string {
base := styles.BaseStyle().Render
muted := styles.Muted().Render
headerLines := []string{}
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-4, t.Background()))
headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background()))
if m.app.Session.Share != nil && m.app.Session.Share.Url != "" {
headerLines = append(headerLines, muted(m.app.Session.Share.Url))
} else {
@@ -256,6 +256,7 @@ func (m *messagesComponent) header() string {
header = styles.BaseStyle().
Width(width).
PaddingLeft(2).
PaddingRight(2).
// Background(t.BackgroundElement()).
BorderLeft(true).
BorderRight(true).
@@ -5,7 +5,7 @@ import (
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
utilComponents "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
@@ -20,7 +20,7 @@ type CompletionItem struct {
}
type CompletionItemI interface {
utilComponents.ListItem
list.ListItem
GetValue() string
DisplayValue() string
}
@@ -30,18 +30,18 @@ func (ci *CompletionItem) Render(selected bool, width int) string {
baseStyle := styles.BaseStyle()
itemStyle := baseStyle.
Background(t.BackgroundElement()).
Width(width).
Padding(0, 1)
if selected {
itemStyle = itemStyle.
Background(t.Background()).
Foreground(t.Primary()).
Bold(true)
}
title := itemStyle.Render(
ci.GetValue(),
ci.DisplayValue(),
)
return title
@@ -68,6 +68,7 @@ type CompletionProvider interface {
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
IsCommand bool
}
type CompletionDialogCompleteItemMsg struct {
@@ -80,6 +81,7 @@ type CompletionDialog interface {
layout.ModelWithView
SetWidth(width int)
IsEmpty() bool
SetProvider(provider CompletionProvider)
}
type completionDialogComponent struct {
@@ -88,7 +90,7 @@ type completionDialogComponent struct {
width int
height int
pseudoSearchTextArea textarea.Model
list utilComponents.List[CompletionItemI]
list list.List[CompletionItemI]
}
type completionDialogKeyMap struct {
@@ -116,10 +118,14 @@ func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
return nil
}
// Check if this is a command completion
isCommand := c.completionProvider.GetId() == "commands"
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
IsCommand: isCommand,
}),
c.close(),
)
@@ -160,7 +166,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
u, cmd := c.list.Update(msg)
c.list = u.(utilComponents.List[CompletionItemI])
c.list = u.(list.List[CompletionItemI])
cmds = append(cmds, cmd)
}
@@ -171,8 +177,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if i == -1 {
return c, nil
}
cmd := c.complete(item)
return c, cmd
return c, c.complete(item)
case key.Matches(msg, completionDialogKeys.Cancel):
// Only close on backspace when there are no characters left
if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
@@ -203,21 +208,20 @@ func (c *completionDialogComponent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// maxWidth := 40
//
// completions := c.list.GetItems()
maxWidth := 40
completions := c.list.GetItems()
// for _, cmd := range completions {
// title := cmd.DisplayValue()
// if len(title) > maxWidth-4 {
// maxWidth = len(title) + 4
// }
// }
for _, cmd := range completions {
title := cmd.DisplayValue()
if len(title) > maxWidth-4 {
maxWidth = len(title) + 4
}
}
// c.list.SetMaxWidth(maxWidth)
c.list.SetMaxWidth(maxWidth)
return baseStyle.Padding(0, 0).
Background(t.BackgroundSubtle()).
Background(t.BackgroundElement()).
Border(lipgloss.ThickBorder()).
BorderTop(false).
BorderBottom(false).
@@ -236,6 +240,17 @@ func (c *completionDialogComponent) IsEmpty() bool {
return c.list.IsEmpty()
}
func (c *completionDialogComponent) SetProvider(provider CompletionProvider) {
if c.completionProvider.GetId() != provider.GetId() {
c.completionProvider = provider
items, err := provider.GetChildEntries("")
if err != nil {
status.Error(err.Error())
}
c.list.SetItems(items)
}
}
func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog {
ti := textarea.New()
@@ -244,10 +259,10 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet
status.Error(err.Error())
}
li := utilComponents.NewListComponent(
li := list.NewListComponent(
items,
7,
"No matching files",
"No matches",
false,
)
@@ -5,8 +5,8 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
components "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
@@ -48,7 +48,7 @@ type sessionDialog struct {
height int
modal *modal.Modal
selectedSessionID string
list components.List[sessionItem]
list list.List[sessionItem]
}
func (s *sessionDialog) Init() tea.Cmd {
@@ -77,7 +77,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(components.List[sessionItem])
s.list = listModel.(list.List[sessionItem])
return s, cmd
}
@@ -98,7 +98,7 @@ func NewSessionDialog(app *app.App) SessionDialog {
sessionItems = append(sessionItems, sessionItem{session: sess})
}
list := components.NewListComponent(
list := list.NewListComponent(
sessionItems,
10, // maxVisibleSessions
"No sessions available",
@@ -2,8 +2,8 @@ package dialog
import (
tea "github.com/charmbracelet/bubbletea/v2"
list "github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
components "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
@@ -49,7 +49,7 @@ type themeDialog struct {
height int
modal *modal.Modal
list components.List[themeItem]
list list.List[themeItem]
}
func (t *themeDialog) Init() tea.Cmd {
@@ -84,7 +84,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
listModel, cmd := t.list.Update(msg)
t.list = listModel.(components.List[themeItem])
t.list = listModel.(list.List[themeItem])
return t, cmd
}
@@ -110,7 +110,7 @@ func NewThemeDialog() ThemeDialog {
}
}
list := components.NewListComponent(
list := list.NewListComponent(
themeItems,
10, // maxVisibleThemes
"No themes available",
@@ -1,11 +1,10 @@
package utilComponents
package list
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
)
type ListItem interface {
@@ -116,18 +115,13 @@ func (c *listComponent[T]) SetSelectedIndex(idx int) {
}
func (c *listComponent[T]) View() string {
baseStyle := styles.BaseStyle()
items := c.items
maxWidth := c.maxWidth
maxVisibleItems := min(c.maxVisibleItems, len(items))
startIdx := 0
if len(items) <= 0 {
return baseStyle.
Padding(0, 1).
Width(maxWidth).
Render(c.fallbackMsg)
return c.fallbackMsg
}
if len(items) > maxVisibleItems {
@@ -19,6 +19,7 @@ type Container interface {
MaxWidth() int
Alignment() lipgloss.Position
GetPosition() (x, y int)
GetContent() ModelWithView
}
type container struct {
@@ -177,6 +178,11 @@ func (c *container) GetPosition() (x, y int) {
return c.x, c.y
}
// GetContent returns the content of the container
func (c *container) GetContent() ModelWithView {
return c.content
}
type ContainerOption func(*container)
func NewContainer(content ModelWithView, options ...ContainerOption) Container {
+20 -8
View File
@@ -22,6 +22,7 @@ type chatPage struct {
messages layout.Container
layout layout.FlexLayout
completionDialog dialog.CompletionDialog
completionManager *completions.CompletionManager
showCompletionDialog bool
}
@@ -94,13 +95,20 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if p.showCompletionDialog {
// Get the current text from the editor to determine which provider to use
editorModel := p.editor.GetContent().(interface{ GetValue() string })
currentInput := editorModel.GetValue()
provider := p.completionManager.GetProvider(currentInput)
p.completionDialog.SetProvider(provider)
context, contextCmd := p.completionDialog.Update(msg)
p.completionDialog = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
// Doesn't forward event if enter key is pressed
// Doesn't forward event if enter key is pressed and there are completions
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "enter" && !p.completionDialog.IsEmpty() {
if keyMsg.String() == "enter" { // && !p.completionDialog.IsEmpty() {
return p, tea.Batch(cmds...)
}
}
@@ -149,8 +157,10 @@ func (p *chatPage) View() string {
}
func NewChatPage(app *app.App) layout.ModelWithView {
cg := completions.NewFileAndFolderContextGroup()
completionDialog := dialog.NewCompletionDialogComponent(cg)
completionManager := completions.NewCompletionManager(app)
initialProvider := completionManager.GetProvider("")
completionDialog := dialog.NewCompletionDialogComponent(initialProvider)
messagesContainer := layout.NewContainer(
chat.NewMessagesComponent(app),
)
@@ -159,11 +169,13 @@ func NewChatPage(app *app.App) layout.ModelWithView {
layout.WithMaxWidth(layout.Current.Container.Width),
layout.WithAlignCenter(),
)
return &chatPage{
app: app,
editor: editorContainer,
messages: messagesContainer,
completionDialog: completionDialog,
app: app,
editor: editorContainer,
messages: messagesContainer,
completionDialog: completionDialog,
completionManager: completionManager,
layout: layout.NewFlexLayout(
layout.WithPanes(messagesContainer, editorContainer),
layout.WithDirection(layout.FlexDirectionVertical),
+25 -5
View File
@@ -2,6 +2,7 @@ package tui
import (
"context"
"log/slog"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/key"
@@ -78,33 +79,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
if a.modal != nil {
isModalTrigger := false
bypassModal := false
if _, ok := msg.(modal.CloseModalMsg); ok {
a.modal = nil
return a, nil
}
if msg, ok := msg.(tea.KeyMsg); ok {
switch msg.String() {
case "esc":
a.modal = nil
return a, nil
case "ctrl+c":
if _, ok := a.modal.(dialog.QuitDialog); !ok {
if _, ok := a.modal.(dialog.QuitDialog); ok {
return a, tea.Quit
} else {
quitDialog := dialog.NewQuitDialog()
a.modal = quitDialog
return a, nil
}
}
// don't send commands to the modal
for _, cmdDef := range a.app.Commands {
if key.Matches(msg, cmdDef.KeyBinding) {
isModalTrigger = true
bypassModal = true
break
}
}
}
if !isModalTrigger {
// thanks i hate this
switch msg.(type) {
case tea.WindowSizeMsg:
bypassModal = true
case client.EventSessionUpdated:
bypassModal = true
case client.EventMessageUpdated:
bypassModal = true
case cursor.BlinkMsg:
bypassModal = true
case spinner.TickMsg:
bypassModal = true
}
if !bypassModal {
updatedModal, cmd := a.modal.Update(msg)
a.modal = updatedModal.(layout.Modal)
return a, cmd
@@ -112,7 +132,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch msg := msg.(type) {
case commands.ExecuteCommandMsg:
switch msg.Name {
case "quit":
@@ -143,6 +162,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
helpDialog := dialog.NewHelpDialog(helpBindings...)
a.modal = helpDialog
}
slog.Info("Execute command", "cmds", cmds)
return a, tea.Batch(cmds...)
case tea.BackgroundColorMsg:
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 75 KiB