summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-07-21 19:10:51 -0400
committerFrank <[email protected]>2025-07-21 19:10:57 -0400
commit5611ef8b28216aa9dd2ceb6ed17d5779a29154f6 (patch)
treea58c6dceb5f0e6243a6cbe3e4dc87a259119fd10 /packages
parentbec796e3c3c097bfc7bb9090729ec23573151d79 (diff)
downloadopencode-5611ef8b28216aa9dd2ceb6ed17d5779a29154f6.tar.gz
opencode-5611ef8b28216aa9dd2ceb6ed17d5779a29154f6.zip
wip: vscode extension
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/cli/cmd/tui.ts11
-rw-r--r--packages/opencode/src/ide/index.ts74
-rw-r--r--packages/tui/internal/commands/command.go22
-rw-r--r--packages/tui/internal/components/ide/ide.go112
-rw-r--r--packages/tui/internal/tui/tui.go32
-rw-r--r--packages/tui/sdk/event.go71
6 files changed, 319 insertions, 3 deletions
diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts
index aa49a8567..388aa743c 100644
--- a/packages/opencode/src/cli/cmd/tui.ts
+++ b/packages/opencode/src/cli/cmd/tui.ts
@@ -12,6 +12,7 @@ import { Bus } from "../../bus"
import { Log } from "../../util/log"
import { FileWatcher } from "../../file/watch"
import { Mode } from "../../session/mode"
+import { Ide } from "../../ide"
export const TuiCommand = cmd({
command: "$0 [project]",
@@ -116,6 +117,16 @@ export const TuiCommand = cmd({
})
.catch(() => {})
})()
+ ;(async () => {
+ if (Ide.alreadyInstalled()) return
+ const ide = await Ide.ide()
+ if (ide === "unknown") return
+ await Ide.install(ide)
+ .then(() => {
+ Bus.publish(Ide.Event.Installed, { ide })
+ })
+ .catch(() => {})
+ })()
await proc.exited
server.stop()
diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts
new file mode 100644
index 000000000..780947135
--- /dev/null
+++ b/packages/opencode/src/ide/index.ts
@@ -0,0 +1,74 @@
+import { $ } from "bun"
+import { z } from "zod"
+import { NamedError } from "../util/error"
+import { Log } from "../util/log"
+import { Bus } from "../bus"
+
+const SUPPORTED_IDES = ["Windsurf", "Visual Studio Code", "Cursor", "VSCodium"] as const
+
+export namespace Ide {
+ const log = Log.create({ service: "ide" })
+
+ export const Event = {
+ Installed: Bus.event(
+ "ide.installed",
+ z.object({
+ ide: z.string(),
+ }),
+ ),
+ }
+
+ export type Ide = Awaited<ReturnType<typeof ide>>
+
+ export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}))
+
+ export const InstallFailedError = NamedError.create(
+ "InstallFailedError",
+ z.object({
+ stderr: z.string(),
+ }),
+ )
+
+ export async function ide() {
+ if (process.env["TERM_PROGRAM"] === "vscode") {
+ const v = process.env["GIT_ASKPASS"]
+ for (const ide of SUPPORTED_IDES) {
+ if (v?.includes(ide)) return ide
+ }
+ }
+ return "unknown"
+ }
+
+ export function alreadyInstalled() {
+ return process.env["OPENCODE_CALLER"] === "vscode"
+ }
+
+ export async function install(ide: Ide) {
+ const cmd = (() => {
+ switch (ide) {
+ case "Windsurf":
+ return $`windsurf --install-extension sst-dev.opencode`
+ case "Visual Studio Code":
+ return $`code --install-extension sst-dev.opencode`
+ case "Cursor":
+ return $`cursor --install-extension sst-dev.opencode`
+ case "VSCodium":
+ return $`codium --install-extension sst-dev.opencode`
+ default:
+ throw new Error(`Unknown IDE: ${ide}`)
+ }
+ })()
+ // TODO: check OPENCODE_CALLER
+ const result = await cmd.quiet().throws(false)
+ log.info("installed", {
+ ide,
+ stdout: result.stdout.toString(),
+ stderr: result.stderr.toString(),
+ })
+ if (result.exitCode !== 0)
+ throw new InstallFailedError({
+ stderr: result.stderr.toString("utf8"),
+ })
+ if (result.stdout.toString().includes("already installed")) throw new AlreadyInstalledError({})
+ }
+}
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
index dde49e824..6015ab85b 100644
--- a/packages/tui/internal/commands/command.go
+++ b/packages/tui/internal/commands/command.go
@@ -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() {
diff --git a/packages/tui/internal/components/ide/ide.go b/packages/tui/internal/components/ide/ide.go
new file mode 100644
index 000000000..cb10f0fc9
--- /dev/null
+++ b/packages/tui/internal/components/ide/ide.go
@@ -0,0 +1,112 @@
+package ide
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/lipgloss/v2/compat"
+ "github.com/sst/opencode/internal/styles"
+ "github.com/sst/opencode/internal/theme"
+)
+
+type IdeComponent interface {
+ tea.ViewModel
+ SetSize(width, height int) tea.Cmd
+ SetBackgroundColor(color compat.AdaptiveColor)
+}
+
+type ideComponent struct {
+ width, height int
+ background *compat.AdaptiveColor
+}
+
+func (c *ideComponent) SetSize(width, height int) tea.Cmd {
+ c.width = width
+ c.height = height
+ return nil
+}
+
+func (c *ideComponent) SetBackgroundColor(color compat.AdaptiveColor) {
+ c.background = &color
+}
+
+func (c *ideComponent) View() string {
+ t := theme.CurrentTheme()
+
+ triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true)
+ descriptionStyle := styles.NewStyle().Foreground(t.Text())
+
+ if c.background != nil {
+ triggerStyle = triggerStyle.Background(*c.background)
+ descriptionStyle = descriptionStyle.Background(*c.background)
+ }
+
+ // VSCode shortcuts data
+ shortcuts := []struct {
+ shortcut string
+ description string
+ }{
+ {"Cmd+Esc", "open opencode in VS Code"},
+ {"Cmd+Opt+K", "insert file from VS Code"},
+ }
+
+ // Calculate column widths
+ maxShortcutWidth := 0
+ maxDescriptionWidth := 0
+
+ for _, shortcut := range shortcuts {
+ if len(shortcut.shortcut) > maxShortcutWidth {
+ maxShortcutWidth = len(shortcut.shortcut)
+ }
+ if len(shortcut.description) > maxDescriptionWidth {
+ maxDescriptionWidth = len(shortcut.description)
+ }
+ }
+
+ // Add padding between columns
+ columnPadding := 3
+
+ // Build the output
+ var output strings.Builder
+
+ maxWidth := 0
+ for _, shortcut := range shortcuts {
+ // Pad each column to align properly
+ shortcutText := fmt.Sprintf("%-*s", maxShortcutWidth, shortcut.shortcut)
+ description := fmt.Sprintf("%-*s", maxDescriptionWidth, shortcut.description)
+
+ // Apply styles and combine
+ line := triggerStyle.Render(shortcutText) +
+ triggerStyle.Render(strings.Repeat(" ", columnPadding)) +
+ descriptionStyle.Render(description)
+
+ output.WriteString(line + "\n")
+ maxWidth = max(maxWidth, lipgloss.Width(line))
+ }
+
+ // Remove trailing newline
+ result := strings.TrimSuffix(output.String(), "\n")
+ if c.background != nil {
+ result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
+ }
+
+ return result
+}
+
+type Option func(*ideComponent)
+
+func WithBackground(background compat.AdaptiveColor) Option {
+ return func(c *ideComponent) {
+ c.background = &background
+ }
+}
+
+func New(opts ...Option) IdeComponent {
+ c := &ideComponent{}
+ for _, opt := range opts {
+ opt(c)
+ }
+ return c
+}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 6ff8f9282..bd0eaeada 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -22,6 +22,7 @@ import (
cmdcomp "github.com/sst/opencode/internal/components/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/fileviewer"
+ "github.com/sst/opencode/internal/components/ide"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
@@ -347,6 +348,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
"opencode updated to "+msg.Properties.Version+", restart to apply.",
toast.WithTitle("New version installed"),
)
+ case opencode.EventListResponseEventIdeInstalled:
+ return a, toast.NewSuccessToast(
+ "Installed the opencode extension in "+msg.Properties.Ide,
+ toast.WithTitle(msg.Properties.Ide+" extension installed"),
+ )
case opencode.EventListResponseEventSessionDeleted:
if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID {
a.app.Session = &opencode.Session{}
@@ -623,10 +629,17 @@ func (a appModel) home() string {
logoAndVersion,
styles.WhitespaceStyle(t.Background()),
)
+
+ // Use limit of 4 for vscode, 6 for others
+ limit := 6
+ if os.Getenv("OPENCODE_CALLER") == "vscode" {
+ limit = 4
+ }
+
commandsView := cmdcomp.New(
a.app,
cmdcomp.WithBackground(t.Background()),
- cmdcomp.WithLimit(6),
+ cmdcomp.WithLimit(limit),
)
cmds := lipgloss.PlaceHorizontal(
effectiveWidth,
@@ -635,6 +648,19 @@ func (a appModel) home() string {
styles.WhitespaceStyle(t.Background()),
)
+ // Add VSCode shortcuts if in VSCode environment
+ var ideShortcuts string
+ if os.Getenv("OPENCODE_CALLER") == "vscode" {
+ ideView := ide.New()
+ ideView.SetBackgroundColor(t.Background())
+ ideShortcuts = lipgloss.PlaceHorizontal(
+ effectiveWidth,
+ lipgloss.Center,
+ ideView.View(),
+ styles.WhitespaceStyle(t.Background()),
+ )
+ }
+
lines := []string{}
lines = append(lines, "")
lines = append(lines, "")
@@ -642,6 +668,10 @@ func (a appModel) home() string {
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, cmds)
+ if os.Getenv("OPENCODE_CALLER") == "vscode" {
+ lines = append(lines, "")
+ lines = append(lines, ideShortcuts)
+ }
lines = append(lines, "")
lines = append(lines, "")
diff --git a/packages/tui/sdk/event.go b/packages/tui/sdk/event.go
index 9002d2aac..00761f4fe 100644
--- a/packages/tui/sdk/event.go
+++ b/packages/tui/sdk/event.go
@@ -52,6 +52,7 @@ type EventListResponse struct {
// [EventListResponseEventPermissionUpdatedProperties],
// [EventListResponseEventFileEditedProperties],
// [EventListResponseEventInstallationUpdatedProperties],
+ // [EventListResponseEventIdeInstalledProperties],
// [EventListResponseEventMessageUpdatedProperties],
// [EventListResponseEventMessageRemovedProperties],
// [EventListResponseEventMessagePartUpdatedProperties],
@@ -96,6 +97,7 @@ func (r *EventListResponse) UnmarshalJSON(data []byte) (err error) {
// [EventListResponseEventLspClientDiagnostics],
// [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited],
// [EventListResponseEventInstallationUpdated],
+// [EventListResponseEventIdeInstalled],
// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved],
// [EventListResponseEventMessagePartUpdated],
// [EventListResponseEventStorageWrite], [EventListResponseEventSessionUpdated],
@@ -109,6 +111,7 @@ func (r EventListResponse) AsUnion() EventListResponseUnion {
// Union satisfied by [EventListResponseEventLspClientDiagnostics],
// [EventListResponseEventPermissionUpdated], [EventListResponseEventFileEdited],
// [EventListResponseEventInstallationUpdated],
+// [EventListResponseEventIdeInstalled],
// [EventListResponseEventMessageUpdated], [EventListResponseEventMessageRemoved],
// [EventListResponseEventMessagePartUpdated],
// [EventListResponseEventStorageWrite], [EventListResponseEventSessionUpdated],
@@ -145,6 +148,11 @@ func init() {
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
+ Type: reflect.TypeOf(EventListResponseEventIdeInstalled{}),
+ DiscriminatorValue: "ide.installed",
+ },
+ apijson.UnionVariant{
+ TypeFilter: gjson.JSON,
Type: reflect.TypeOf(EventListResponseEventMessageUpdated{}),
DiscriminatorValue: "message.updated",
},
@@ -462,6 +470,66 @@ func (r EventListResponseEventInstallationUpdatedType) IsKnown() bool {
return false
}
+type EventListResponseEventIdeInstalled struct {
+ Properties EventListResponseEventIdeInstalledProperties `json:"properties,required"`
+ Type EventListResponseEventIdeInstalledType `json:"type,required"`
+ JSON eventListResponseEventIdeInstalledJSON `json:"-"`
+}
+
+// eventListResponseEventIdeInstalledJSON contains the JSON metadata for the
+// struct [EventListResponseEventIdeInstalled]
+type eventListResponseEventIdeInstalledJSON struct {
+ Properties apijson.Field
+ Type apijson.Field
+ raw string
+ ExtraFields map[string]apijson.Field
+}
+
+func (r *EventListResponseEventIdeInstalled) UnmarshalJSON(data []byte) (err error) {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+func (r eventListResponseEventIdeInstalledJSON) RawJSON() string {
+ return r.raw
+}
+
+func (r EventListResponseEventIdeInstalled) implementsEventListResponse() {}
+
+type EventListResponseEventIdeInstalledProperties struct {
+ Ide string `json:"ide,required"`
+ JSON eventListResponseEventIdeInstalledPropertiesJSON `json:"-"`
+}
+
+// eventListResponseEventIdeInstalledPropertiesJSON contains the JSON
+// metadata for the struct [EventListResponseEventIdeInstalledProperties]
+type eventListResponseEventIdeInstalledPropertiesJSON struct {
+ Ide apijson.Field
+ raw string
+ ExtraFields map[string]apijson.Field
+}
+
+func (r *EventListResponseEventIdeInstalledProperties) UnmarshalJSON(data []byte) (err error) {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+func (r eventListResponseEventIdeInstalledPropertiesJSON) RawJSON() string {
+ return r.raw
+}
+
+type EventListResponseEventIdeInstalledType string
+
+const (
+ EventListResponseEventIdeInstalledTypeIdeInstalled EventListResponseEventIdeInstalledType = "ide.installed"
+)
+
+func (r EventListResponseEventIdeInstalledType) IsKnown() bool {
+ switch r {
+ case EventListResponseEventIdeInstalledTypeIdeInstalled:
+ return true
+ }
+ return false
+}
+
type EventListResponseEventMessageUpdated struct {
Properties EventListResponseEventMessageUpdatedProperties `json:"properties,required"`
Type EventListResponseEventMessageUpdatedType `json:"type,required"`
@@ -1166,6 +1234,7 @@ const (
EventListResponseTypePermissionUpdated EventListResponseType = "permission.updated"
EventListResponseTypeFileEdited EventListResponseType = "file.edited"
EventListResponseTypeInstallationUpdated EventListResponseType = "installation.updated"
+ EventListResponseTypeIdeInstalled EventListResponseType = "ide.installed"
EventListResponseTypeMessageUpdated EventListResponseType = "message.updated"
EventListResponseTypeMessageRemoved EventListResponseType = "message.removed"
EventListResponseTypeMessagePartUpdated EventListResponseType = "message.part.updated"
@@ -1179,7 +1248,7 @@ const (
func (r EventListResponseType) IsKnown() bool {
switch r {
- case EventListResponseTypeLspClientDiagnostics, EventListResponseTypePermissionUpdated, EventListResponseTypeFileEdited, EventListResponseTypeInstallationUpdated, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeStorageWrite, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated:
+ case EventListResponseTypeLspClientDiagnostics, EventListResponseTypePermissionUpdated, EventListResponseTypeFileEdited, EventListResponseTypeInstallationUpdated, EventListResponseTypeIdeInstalled, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeStorageWrite, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionIdle, EventListResponseTypeSessionError, EventListResponseTypeFileWatcherUpdated:
return true
}
return false