mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3664b09812 | |||
| 7050cc0ac3 | |||
| 4d3d63294d | |||
| 6bc61cbc2d | |||
| 01d351bebe | |||
| dbba4a97aa | |||
| 0dc586faef |
@@ -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
|
||||
})
|
||||
@@ -53,9 +53,12 @@ export namespace BunProc {
|
||||
if (parsed.dependencies[pkg] === version) return mod
|
||||
parsed.dependencies[pkg] = version
|
||||
await Bun.write(pkgjson, JSON.stringify(parsed, null, 2))
|
||||
await BunProc.run(["install"], {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
await BunProc.run(
|
||||
["install", "--registry", "https://registry.npmjs.org/"],
|
||||
{
|
||||
cwd: Global.Path.cache,
|
||||
},
|
||||
).catch((e) => {
|
||||
new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthAnthropic } from "../../auth/anthropic"
|
||||
import { AuthGithubCopilot } from "../../auth/github-copilot"
|
||||
import { AuthCopilot } from "../../auth/copilot"
|
||||
import { Auth } from "../../auth"
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
@@ -17,7 +17,7 @@ export const AuthCommand = cmd({
|
||||
.command(AuthLogoutCommand)
|
||||
.command(AuthListCommand)
|
||||
.demandCommand(),
|
||||
async handler() { },
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
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))
|
||||
const deviceInfo = await AuthGithubCopilot.authorize()
|
||||
const deviceInfo = await copilot.authorize()
|
||||
|
||||
prompts.note(
|
||||
`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
|
||||
@@ -163,13 +164,19 @@ export const AuthLoginCommand = cmd({
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, deviceInfo.interval * 1000),
|
||||
)
|
||||
const status = await AuthGithubCopilot.poll(deviceInfo.device)
|
||||
if (status === "pending") continue
|
||||
if (status === "complete") {
|
||||
const response = await copilot.poll(deviceInfo.device)
|
||||
if (response.status === "pending") continue
|
||||
if (response.status === "success") {
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: response.refresh,
|
||||
access: response.access,
|
||||
expires: response.expires,
|
||||
})
|
||||
spinner.stop("Login successful")
|
||||
break
|
||||
}
|
||||
if (status === "failed") {
|
||||
if (response.status === "failed") {
|
||||
spinner.stop("Failed to authorize", 1)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -115,7 +115,9 @@ export namespace Installation {
|
||||
case "bun":
|
||||
return $`bun install -g opencode-ai@${target}`
|
||||
case "brew":
|
||||
return $`brew install sst/tap/opencode`
|
||||
return $`brew install sst/tap/opencode`.env({
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1",
|
||||
})
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export namespace ModelsDev {
|
||||
}
|
||||
refresh()
|
||||
const json = await data()
|
||||
return JSON.parse(json)
|
||||
return JSON.parse(json) as Record<string, Provider>
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Tool } from "../tool/tool"
|
||||
import { WriteTool } from "../tool/write"
|
||||
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
|
||||
import { AuthAnthropic } from "../auth/anthropic"
|
||||
import { AuthGithubCopilot } from "../auth/github-copilot"
|
||||
import { AuthCopilot } from "../auth/copilot"
|
||||
import { ModelsDev } from "./models"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Auth } from "../auth"
|
||||
@@ -68,8 +68,10 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
"github-copilot": async (provider) => {
|
||||
const info = await AuthGithubCopilot.access()
|
||||
if (!info) return false
|
||||
const copilot = await AuthCopilot()
|
||||
if (!copilot) return false
|
||||
let info = await Auth.get("github-copilot")
|
||||
if (!info || info.type !== "oauth") return false
|
||||
|
||||
if (provider && provider.models) {
|
||||
for (const model of Object.values(provider.models)) {
|
||||
@@ -84,15 +86,22 @@ export namespace Provider {
|
||||
options: {
|
||||
apiKey: "",
|
||||
async fetch(input: any, init: any) {
|
||||
const token = await AuthGithubCopilot.access()
|
||||
if (!token) throw new Error("GitHub Copilot authentication expired")
|
||||
const info = await Auth.get("github-copilot")
|
||||
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 = {
|
||||
...init.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"Editor-Version": "vscode/1.99.3",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
...copilot.HEADERS,
|
||||
Authorization: `Bearer ${info.access}`,
|
||||
"Openai-Intent": "conversation-edits",
|
||||
}
|
||||
delete headers["x-api-key"]
|
||||
|
||||
@@ -248,7 +248,7 @@ export namespace Session {
|
||||
if (
|
||||
model.info.limit.context &&
|
||||
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({
|
||||
sessionID: input.sessionID,
|
||||
@@ -295,7 +295,7 @@ export namespace Session {
|
||||
draft.title = result.text
|
||||
})
|
||||
})
|
||||
.catch(() => { })
|
||||
.catch(() => {})
|
||||
}
|
||||
const msg: Message.Info = {
|
||||
role: "user",
|
||||
@@ -433,24 +433,6 @@ export namespace Session {
|
||||
}
|
||||
|
||||
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({
|
||||
onStepFinish: async (step) => {
|
||||
log.info("step finish", { finishReason: step.finishReason })
|
||||
@@ -572,7 +554,7 @@ export namespace Session {
|
||||
case "tool-call": {
|
||||
const [match] = next.parts.flatMap((p) =>
|
||||
p.type === "tool-invocation" &&
|
||||
p.toolInvocation.toolCallId === value.toolCallId
|
||||
p.toolInvocation.toolCallId === value.toolCallId
|
||||
? [p]
|
||||
: [],
|
||||
)
|
||||
|
||||
@@ -32,17 +32,19 @@ type EditorComponent interface {
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
Previous() (tea.Model, tea.Cmd)
|
||||
Next() (tea.Model, tea.Cmd)
|
||||
SetInterruptKeyInDebounce(inDebounce bool)
|
||||
}
|
||||
|
||||
type editorComponent struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
textarea textarea.Model
|
||||
attachments []app.Attachment
|
||||
history []string
|
||||
historyIndex int
|
||||
currentMessage string
|
||||
spinner spinner.Model
|
||||
app *app.App
|
||||
width, height int
|
||||
textarea textarea.Model
|
||||
attachments []app.Attachment
|
||||
history []string
|
||||
historyIndex int
|
||||
currentMessage string
|
||||
spinner spinner.Model
|
||||
interruptKeyInDebounce bool
|
||||
}
|
||||
|
||||
func (m *editorComponent) Init() tea.Cmd {
|
||||
@@ -115,9 +117,14 @@ func (m *editorComponent) Content() string {
|
||||
Background(t.BackgroundElement()).
|
||||
Render(textarea)
|
||||
|
||||
hint := base("enter") + muted(" send ")
|
||||
hint := base(m.getSubmitKeyText()) + muted(" send ")
|
||||
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 := ""
|
||||
@@ -263,6 +270,18 @@ func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
|
||||
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 {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.BackgroundElement()
|
||||
@@ -311,11 +330,12 @@ func NewEditorComponent(app *app.App) EditorComponent {
|
||||
ta := createTextArea(nil)
|
||||
|
||||
return &editorComponent{
|
||||
app: app,
|
||||
textarea: ta,
|
||||
history: []string{},
|
||||
historyIndex: 0,
|
||||
currentMessage: "",
|
||||
spinner: s,
|
||||
app: app,
|
||||
textarea: ta,
|
||||
history: []string{},
|
||||
historyIndex: 0,
|
||||
currentMessage: "",
|
||||
spinner: s,
|
||||
interruptKeyInDebounce: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
@@ -25,6 +26,19 @@ import (
|
||||
"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 {
|
||||
width, height int
|
||||
app *app.App
|
||||
@@ -40,6 +54,7 @@ type appModel struct {
|
||||
leaderBinding *key.Binding
|
||||
isLeaderSequence bool
|
||||
toastManager *toast.ToastManager
|
||||
interruptKeyState InterruptKeyState
|
||||
}
|
||||
|
||||
func (a appModel) Init() tea.Cmd {
|
||||
@@ -171,9 +186,32 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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)
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -305,6 +343,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
tm, cmd := a.toastManager.Update(msg)
|
||||
a.toastManager = tm
|
||||
cmds = append(cmds, cmd)
|
||||
case InterruptDebounceTimeoutMsg:
|
||||
// Reset interrupt key state after timeout
|
||||
a.interruptKeyState = InterruptKeyIdle
|
||||
a.editor.SetInterruptKeyInDebounce(false)
|
||||
}
|
||||
|
||||
// update status bar
|
||||
@@ -597,6 +639,7 @@ func NewModel(app *app.App) tea.Model {
|
||||
showCompletionDialog: false,
|
||||
editorContainer: editorContainer,
|
||||
toastManager: toast.NewToastManager(),
|
||||
interruptKeyState: InterruptKeyIdle,
|
||||
layout: layout.NewFlexLayout(
|
||||
[]tea.ViewModel{messagesContainer, editorContainer},
|
||||
layout.WithDirection(layout.FlexDirectionVertical),
|
||||
|
||||
Reference in New Issue
Block a user