diff options
| author | Frank <[email protected]> | 2025-07-21 19:10:51 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-07-21 19:10:57 -0400 |
| commit | 5611ef8b28216aa9dd2ceb6ed17d5779a29154f6 (patch) | |
| tree | a58c6dceb5f0e6243a6cbe3e4dc87a259119fd10 /packages | |
| parent | bec796e3c3c097bfc7bb9090729ec23573151d79 (diff) | |
| download | opencode-5611ef8b28216aa9dd2ceb6ed17d5779a29154f6.tar.gz opencode-5611ef8b28216aa9dd2ceb6ed17d5779a29154f6.zip | |
wip: vscode extension
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui.ts | 11 | ||||
| -rw-r--r-- | packages/opencode/src/ide/index.ts | 74 | ||||
| -rw-r--r-- | packages/tui/internal/commands/command.go | 22 | ||||
| -rw-r--r-- | packages/tui/internal/components/ide/ide.go | 112 | ||||
| -rw-r--r-- | packages/tui/internal/tui/tui.go | 32 | ||||
| -rw-r--r-- | packages/tui/sdk/event.go | 71 |
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 |
