mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3664b09812 | |||
| 7050cc0ac3 | |||
| 4d3d63294d | |||
| 6bc61cbc2d | |||
| 01d351bebe |
@@ -0,0 +1,20 @@
|
|||||||
|
import { Global } from "../global"
|
||||||
|
import { lazy } from "../util/lazy"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export const AuthCopilot = lazy(async () => {
|
||||||
|
const file = Bun.file(path.join(Global.Path.cache, "copilot.ts"))
|
||||||
|
const response = fetch(
|
||||||
|
"https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts",
|
||||||
|
)
|
||||||
|
.then((x) => Bun.write(file, x))
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
if (!file.exists()) {
|
||||||
|
const worked = await response
|
||||||
|
if (!worked) return
|
||||||
|
}
|
||||||
|
const result = await import(file.name!).catch(() => {})
|
||||||
|
if (!result) return
|
||||||
|
return result.AuthCopilot
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AuthAnthropic } from "../../auth/anthropic"
|
import { AuthAnthropic } from "../../auth/anthropic"
|
||||||
import { AuthGithubCopilot } from "../../auth/github-copilot"
|
import { AuthCopilot } from "../../auth/copilot"
|
||||||
import { Auth } from "../../auth"
|
import { Auth } from "../../auth"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
@@ -17,7 +17,7 @@ export const AuthCommand = cmd({
|
|||||||
.command(AuthLogoutCommand)
|
.command(AuthLogoutCommand)
|
||||||
.command(AuthListCommand)
|
.command(AuthListCommand)
|
||||||
.demandCommand(),
|
.demandCommand(),
|
||||||
async handler() { },
|
async handler() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const AuthListCommand = cmd({
|
export const AuthListCommand = cmd({
|
||||||
@@ -148,9 +148,10 @@ export const AuthLoginCommand = cmd({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "github-copilot") {
|
const copilot = await AuthCopilot()
|
||||||
|
if (provider === "github-copilot" && copilot) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
const deviceInfo = await AuthGithubCopilot.authorize()
|
const deviceInfo = await copilot.authorize()
|
||||||
|
|
||||||
prompts.note(
|
prompts.note(
|
||||||
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
|
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
|
||||||
@@ -163,13 +164,19 @@ export const AuthLoginCommand = cmd({
|
|||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
setTimeout(resolve, deviceInfo.interval * 1000),
|
setTimeout(resolve, deviceInfo.interval * 1000),
|
||||||
)
|
)
|
||||||
const status = await AuthGithubCopilot.poll(deviceInfo.device)
|
const response = await copilot.poll(deviceInfo.device)
|
||||||
if (status === "pending") continue
|
if (response.status === "pending") continue
|
||||||
if (status === "complete") {
|
if (response.status === "success") {
|
||||||
|
await Auth.set("github-copilot", {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: response.refresh,
|
||||||
|
access: response.access,
|
||||||
|
expires: response.expires,
|
||||||
|
})
|
||||||
spinner.stop("Login successful")
|
spinner.stop("Login successful")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (status === "failed") {
|
if (response.status === "failed") {
|
||||||
spinner.stop("Failed to authorize", 1)
|
spinner.stop("Failed to authorize", 1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ export namespace Installation {
|
|||||||
case "bun":
|
case "bun":
|
||||||
return $`bun install -g opencode-ai@${target}`
|
return $`bun install -g opencode-ai@${target}`
|
||||||
case "brew":
|
case "brew":
|
||||||
return $`brew install sst/tap/opencode`
|
return $`brew install sst/tap/opencode`.env({
|
||||||
|
HOMEBREW_NO_AUTO_UPDATE: "1",
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown method: ${method}`)
|
throw new Error(`Unknown method: ${method}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type { Tool } from "../tool/tool"
|
|||||||
import { WriteTool } from "../tool/write"
|
import { WriteTool } from "../tool/write"
|
||||||
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
|
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
|
||||||
import { AuthAnthropic } from "../auth/anthropic"
|
import { AuthAnthropic } from "../auth/anthropic"
|
||||||
import { AuthGithubCopilot } from "../auth/github-copilot"
|
import { AuthCopilot } from "../auth/copilot"
|
||||||
import { ModelsDev } from "./models"
|
import { ModelsDev } from "./models"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
@@ -68,8 +68,10 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"github-copilot": async (provider) => {
|
"github-copilot": async (provider) => {
|
||||||
const info = await AuthGithubCopilot.access()
|
const copilot = await AuthCopilot()
|
||||||
if (!info) return false
|
if (!copilot) return false
|
||||||
|
let info = await Auth.get("github-copilot")
|
||||||
|
if (!info || info.type !== "oauth") return false
|
||||||
|
|
||||||
if (provider && provider.models) {
|
if (provider && provider.models) {
|
||||||
for (const model of Object.values(provider.models)) {
|
for (const model of Object.values(provider.models)) {
|
||||||
@@ -84,15 +86,22 @@ export namespace Provider {
|
|||||||
options: {
|
options: {
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
async fetch(input: any, init: any) {
|
async fetch(input: any, init: any) {
|
||||||
const token = await AuthGithubCopilot.access()
|
const info = await Auth.get("github-copilot")
|
||||||
if (!token) throw new Error("GitHub Copilot authentication expired")
|
if (!info || info.type !== "oauth") return
|
||||||
|
if (!info.access || info.expires < Date.now()) {
|
||||||
|
const tokens = await copilot.access(info.refresh)
|
||||||
|
if (!tokens)
|
||||||
|
throw new Error("GitHub Copilot authentication expired")
|
||||||
|
await Auth.set("github-copilot", {
|
||||||
|
type: "oauth",
|
||||||
|
...tokens,
|
||||||
|
})
|
||||||
|
info.access = tokens.access
|
||||||
|
}
|
||||||
const headers = {
|
const headers = {
|
||||||
...init.headers,
|
...init.headers,
|
||||||
Authorization: `Bearer ${token}`,
|
...copilot.HEADERS,
|
||||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
Authorization: `Bearer ${info.access}`,
|
||||||
"Editor-Version": "vscode/1.99.3",
|
|
||||||
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
|
||||||
"Copilot-Integration-Id": "vscode-chat",
|
|
||||||
"Openai-Intent": "conversation-edits",
|
"Openai-Intent": "conversation-edits",
|
||||||
}
|
}
|
||||||
delete headers["x-api-key"]
|
delete headers["x-api-key"]
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export namespace Session {
|
|||||||
if (
|
if (
|
||||||
model.info.limit.context &&
|
model.info.limit.context &&
|
||||||
tokens >
|
tokens >
|
||||||
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
(model.info.limit.context - (model.info.limit.output ?? 0)) * 0.9
|
||||||
) {
|
) {
|
||||||
await summarize({
|
await summarize({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -295,7 +295,7 @@ export namespace Session {
|
|||||||
draft.title = result.text
|
draft.title = result.text
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(() => { })
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
const msg: Message.Info = {
|
const msg: Message.Info = {
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -433,24 +433,6 @@ export namespace Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let text: Message.TextPart | undefined
|
let text: Message.TextPart | undefined
|
||||||
await Bun.write(
|
|
||||||
"/tmp/message.json",
|
|
||||||
JSON.stringify(
|
|
||||||
[
|
|
||||||
...system.map(
|
|
||||||
(x): CoreMessage => ({
|
|
||||||
role: "system",
|
|
||||||
content: x,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
...convertToCoreMessages(
|
|
||||||
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
onStepFinish: async (step) => {
|
onStepFinish: async (step) => {
|
||||||
log.info("step finish", { finishReason: step.finishReason })
|
log.info("step finish", { finishReason: step.finishReason })
|
||||||
@@ -572,7 +554,7 @@ export namespace Session {
|
|||||||
case "tool-call": {
|
case "tool-call": {
|
||||||
const [match] = next.parts.flatMap((p) =>
|
const [match] = next.parts.flatMap((p) =>
|
||||||
p.type === "tool-invocation" &&
|
p.type === "tool-invocation" &&
|
||||||
p.toolInvocation.toolCallId === value.toolCallId
|
p.toolInvocation.toolCallId === value.toolCallId
|
||||||
? [p]
|
? [p]
|
||||||
: [],
|
: [],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,17 +32,19 @@ type EditorComponent interface {
|
|||||||
Newline() (tea.Model, tea.Cmd)
|
Newline() (tea.Model, tea.Cmd)
|
||||||
Previous() (tea.Model, tea.Cmd)
|
Previous() (tea.Model, tea.Cmd)
|
||||||
Next() (tea.Model, tea.Cmd)
|
Next() (tea.Model, tea.Cmd)
|
||||||
|
SetInterruptKeyInDebounce(inDebounce bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
type editorComponent struct {
|
type editorComponent struct {
|
||||||
app *app.App
|
app *app.App
|
||||||
width, height int
|
width, height int
|
||||||
textarea textarea.Model
|
textarea textarea.Model
|
||||||
attachments []app.Attachment
|
attachments []app.Attachment
|
||||||
history []string
|
history []string
|
||||||
historyIndex int
|
historyIndex int
|
||||||
currentMessage string
|
currentMessage string
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
|
interruptKeyInDebounce bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *editorComponent) Init() tea.Cmd {
|
func (m *editorComponent) Init() tea.Cmd {
|
||||||
@@ -115,9 +117,14 @@ func (m *editorComponent) Content() string {
|
|||||||
Background(t.BackgroundElement()).
|
Background(t.BackgroundElement()).
|
||||||
Render(textarea)
|
Render(textarea)
|
||||||
|
|
||||||
hint := base("enter") + muted(" send ")
|
hint := base(m.getSubmitKeyText()) + muted(" send ")
|
||||||
if m.app.IsBusy() {
|
if m.app.IsBusy() {
|
||||||
hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
|
keyText := m.getInterruptKeyText()
|
||||||
|
if m.interruptKeyInDebounce {
|
||||||
|
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
|
||||||
|
} else {
|
||||||
|
hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
model := ""
|
model := ""
|
||||||
@@ -263,6 +270,18 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
|
||||||
|
m.interruptKeyInDebounce = inDebounce
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *editorComponent) getInterruptKeyText() string {
|
||||||
|
return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *editorComponent) getSubmitKeyText() string {
|
||||||
|
return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
|
||||||
|
}
|
||||||
|
|
||||||
func createTextArea(existing *textarea.Model) textarea.Model {
|
func createTextArea(existing *textarea.Model) textarea.Model {
|
||||||
t := theme.CurrentTheme()
|
t := theme.CurrentTheme()
|
||||||
bgColor := t.BackgroundElement()
|
bgColor := t.BackgroundElement()
|
||||||
@@ -311,11 +330,12 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
|||||||
ta := createTextArea(nil)
|
ta := createTextArea(nil)
|
||||||
|
|
||||||
return &editorComponent{
|
return &editorComponent{
|
||||||
app: app,
|
app: app,
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
history: []string{},
|
history: []string{},
|
||||||
historyIndex: 0,
|
historyIndex: 0,
|
||||||
currentMessage: "",
|
currentMessage: "",
|
||||||
spinner: s,
|
spinner: s,
|
||||||
|
interruptKeyInDebounce: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/v2/key"
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
@@ -25,6 +26,19 @@ import (
|
|||||||
"github.com/sst/opencode/pkg/client"
|
"github.com/sst/opencode/pkg/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
|
||||||
|
type InterruptDebounceTimeoutMsg struct{}
|
||||||
|
|
||||||
|
// InterruptKeyState tracks the state of interrupt key presses for debouncing
|
||||||
|
type InterruptKeyState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
InterruptKeyIdle InterruptKeyState = iota
|
||||||
|
InterruptKeyFirstPress
|
||||||
|
)
|
||||||
|
|
||||||
|
const interruptDebounceTimeout = 1 * time.Second
|
||||||
|
|
||||||
type appModel struct {
|
type appModel struct {
|
||||||
width, height int
|
width, height int
|
||||||
app *app.App
|
app *app.App
|
||||||
@@ -40,6 +54,7 @@ type appModel struct {
|
|||||||
leaderBinding *key.Binding
|
leaderBinding *key.Binding
|
||||||
isLeaderSequence bool
|
isLeaderSequence bool
|
||||||
toastManager *toast.ToastManager
|
toastManager *toast.ToastManager
|
||||||
|
interruptKeyState InterruptKeyState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a appModel) Init() tea.Cmd {
|
func (a appModel) Init() tea.Cmd {
|
||||||
@@ -171,9 +186,32 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Check again for commands that don't require leader
|
// 6. Handle interrupt key debounce for session interrupt
|
||||||
|
interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
|
||||||
|
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() {
|
||||||
|
switch a.interruptKeyState {
|
||||||
|
case InterruptKeyIdle:
|
||||||
|
// First interrupt key press - start debounce timer
|
||||||
|
a.interruptKeyState = InterruptKeyFirstPress
|
||||||
|
a.editor.SetInterruptKeyInDebounce(true)
|
||||||
|
return a, tea.Tick(interruptDebounceTimeout, func(t time.Time) tea.Msg {
|
||||||
|
return InterruptDebounceTimeoutMsg{}
|
||||||
|
})
|
||||||
|
case InterruptKeyFirstPress:
|
||||||
|
// Second interrupt key press within timeout - actually interrupt
|
||||||
|
a.interruptKeyState = InterruptKeyIdle
|
||||||
|
a.editor.SetInterruptKeyInDebounce(false)
|
||||||
|
return a, util.CmdHandler(commands.ExecuteCommandMsg(interruptCommand))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Check again for commands that don't require leader (excluding interrupt when busy)
|
||||||
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
|
matches := a.app.Commands.Matches(msg, a.isLeaderSequence)
|
||||||
if len(matches) > 0 {
|
if len(matches) > 0 {
|
||||||
|
// Skip interrupt key if we're in debounce mode and app is busy
|
||||||
|
if interruptCommand.Matches(msg, a.isLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
|
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +343,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
tm, cmd := a.toastManager.Update(msg)
|
tm, cmd := a.toastManager.Update(msg)
|
||||||
a.toastManager = tm
|
a.toastManager = tm
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
|
case InterruptDebounceTimeoutMsg:
|
||||||
|
// Reset interrupt key state after timeout
|
||||||
|
a.interruptKeyState = InterruptKeyIdle
|
||||||
|
a.editor.SetInterruptKeyInDebounce(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// update status bar
|
// update status bar
|
||||||
@@ -597,6 +639,7 @@ func NewModel(app *app.App) tea.Model {
|
|||||||
showCompletionDialog: false,
|
showCompletionDialog: false,
|
||||||
editorContainer: editorContainer,
|
editorContainer: editorContainer,
|
||||||
toastManager: toast.NewToastManager(),
|
toastManager: toast.NewToastManager(),
|
||||||
|
interruptKeyState: InterruptKeyIdle,
|
||||||
layout: layout.NewFlexLayout(
|
layout: layout.NewFlexLayout(
|
||||||
[]tea.ViewModel{messagesContainer, editorContainer},
|
[]tea.ViewModel{messagesContainer, editorContainer},
|
||||||
layout.WithDirection(layout.FlexDirectionVertical),
|
layout.WithDirection(layout.FlexDirectionVertical),
|
||||||
|
|||||||
Reference in New Issue
Block a user