Frank 7 месяцев назад
Родитель
Сommit
5611ef8b28

+ 11 - 0
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()

+ 74 - 0
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({})
+  }
+}

+ 21 - 1
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() {

+ 112 - 0
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
+}

+ 31 - 1
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, "")
 

+ 70 - 1
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],
@@ -143,6 +146,11 @@ func init() {
 			Type:               reflect.TypeOf(EventListResponseEventInstallationUpdated{}),
 			DiscriminatorValue: "installation.updated",
 		},
+		apijson.UnionVariant{
+			TypeFilter:         gjson.JSON,
+			Type:               reflect.TypeOf(EventListResponseEventIdeInstalled{}),
+			DiscriminatorValue: "ide.installed",
+		},
 		apijson.UnionVariant{
 			TypeFilter:         gjson.JSON,
 			Type:               reflect.TypeOf(EventListResponseEventMessageUpdated{}),
@@ -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

+ 4 - 0
sdks/vscode/images/button-dark.svg

@@ -0,0 +1,4 @@
+<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="black"/>
+<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="black"/>
+</svg>

+ 4 - 0
sdks/vscode/images/button-light.svg

@@ -0,0 +1,4 @@
+<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="white"/>
+<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="white"/>
+</svg>

+ 14 - 1
sdks/vscode/package.json

@@ -26,13 +26,26 @@
     "commands": [
       {
         "command": "opencode.openTerminal",
-        "title": "Open Terminal with Opencode"
+        "title": "Open Terminal with Opencode",
+        "icon": {
+          "light": "images/button-dark.svg",
+          "dark": "images/button-light.svg"
+        }
       },
       {
         "command": "opencode.addFilepathToTerminal",
         "title": "Add Filepath to Terminal"
       }
     ],
+    "menus": {
+      "editor/title": [
+        {
+          "command": "opencode.openTerminal",
+          "when": "editorTextFocus",
+          "group": "navigation"
+        }
+      ]
+    },
     "keybindings": [
       {
         "command": "opencode.openTerminal",

+ 25 - 25
sdks/vscode/src/extension.ts

@@ -1,10 +1,10 @@
 // This method is called when your extension is deactivated
 export function deactivate() {}
 
-import * as vscode from "vscode"
+import * as vscode from "vscode";
 
 export function activate(context: vscode.ExtensionContext) {
-  const TERMINAL_NAME = "opencode Terminal"
+  const TERMINAL_NAME = "opencode Terminal";
 
   // Register command to open terminal in split screen and run opencode
   let openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => {
@@ -15,56 +15,56 @@ export function activate(context: vscode.ExtensionContext) {
         viewColumn: vscode.ViewColumn.Beside,
         preserveFocus: false,
       },
-    })
+    });
 
-    terminal.show()
-    terminal.sendText("OPENCODE_THEME=system OPENCODE_CALLER=vscode opencode")
-  })
+    terminal.show();
+    terminal.sendText("OPENCODE_THEME=system OPENCODE_CALLER=vscode opencode");
+  });
 
   // Register command to add filepath to terminal
   let addFilepathDisposable = vscode.commands.registerCommand("opencode.addFilepathToTerminal", async () => {
-    const activeEditor = vscode.window.activeTextEditor
+    const activeEditor = vscode.window.activeTextEditor;
 
     if (!activeEditor) {
-      vscode.window.showInformationMessage("No active file to get path from")
-      return
+      vscode.window.showInformationMessage("No active file to get path from");
+      return;
     }
 
-    const document = activeEditor.document
-    const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)
+    const document = activeEditor.document;
+    const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri);
 
     if (!workspaceFolder) {
-      vscode.window.showInformationMessage("File is not in a workspace")
-      return
+      vscode.window.showInformationMessage("File is not in a workspace");
+      return;
     }
 
     // Get the relative path from workspace root
-    const relativePath = vscode.workspace.asRelativePath(document.uri)
-    let filepathWithAt = `@${relativePath}`
+    const relativePath = vscode.workspace.asRelativePath(document.uri);
+    let filepathWithAt = `@${relativePath}`;
 
     // Check if there's a selection and add line numbers
-    const selection = activeEditor.selection
+    const selection = activeEditor.selection;
     if (!selection.isEmpty) {
       // Convert to 1-based line numbers
-      const startLine = selection.start.line + 1
-      const endLine = selection.end.line + 1
+      const startLine = selection.start.line + 1;
+      const endLine = selection.end.line + 1;
 
       if (startLine === endLine) {
         // Single line selection
-        filepathWithAt += `#L${startLine}`
+        filepathWithAt += `#L${startLine}`;
       } else {
         // Multi-line selection
-        filepathWithAt += `#L${startLine}-${endLine}`
+        filepathWithAt += `#L${startLine}-${endLine}`;
       }
     }
 
     // Get or create terminal
-    let terminal = vscode.window.activeTerminal
+    let terminal = vscode.window.activeTerminal;
     if (terminal?.name === TERMINAL_NAME) {
-      terminal.sendText(filepathWithAt)
-      terminal.show()
+      terminal.sendText(filepathWithAt);
+      terminal.show();
     }
-  })
+  });
 
-  context.subscriptions.push(openTerminalDisposable, addFilepathDisposable)
+  context.subscriptions.push(openTerminalDisposable, addFilepathDisposable);
 }