Compare commits

..

11 Commits

Author SHA1 Message Date
Dax Raad 3664b09812 remove debug code writing to /tmp/message.json 2025-06-24 11:16:17 -04:00
Dax Raad 7050cc0ac3 ignore: fix type errors 2025-06-24 11:09:36 -04:00
Dax Raad 4d3d63294d externalize github copilot code 2025-06-24 10:42:19 -04:00
Tom 6bc61cbc2d feat(tui): add debounce logic to escape key interrupt (#169)
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: adamdottv <2363879+adamdottv@users.noreply.github.com>
2025-06-24 06:31:02 -05:00
Dax Raad 01d351bebe add HOMEBREW_NO_AUTO_UPDATE to brew upgrades 2025-06-23 20:36:08 -04:00
Dax Raad dbba4a97aa force use npm registry 2025-06-23 20:23:37 -04:00
GitMurf 0dc586faef fix: typescript error (any) from models (#347) 2025-06-23 18:44:57 -04:00
Dax Raad f19c6b05f2 glob tool should respect .gitignore 2025-06-23 17:37:32 -04:00
Dax Raad bc34f08333 bundle models.dev at build time and ignore refresh errors 2025-06-23 14:50:19 -04:00
Dax Raad b7ee16aabd ignore: remove opencode.json 2025-06-23 14:32:57 -04:00
Lucas Grzegorczyk ed1b0d97bf Fix project folder name starting with "-" in data (#323). Note old session data will still be in the old format in ~/.local/share/opencode/projects - you can remove the leading dash to recover the, 2025-06-23 14:31:51 -04:00
14 changed files with 168 additions and 78 deletions
+1 -7
View File
@@ -1,9 +1,3 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"sentry": {
"type": "remote",
"url": "https://mcp.sentry.dev/sse"
}
}
"$schema": "https://opencode.ai/config.json"
}
+1 -1
View File
@@ -46,7 +46,7 @@ export namespace App {
const data = path.join(
Global.Path.data,
"project",
git ? git.split(path.sep).join("-") : "global",
git ? git.split(path.sep).filter(Boolean).join("-") : "global",
)
const stateFile = Bun.file(path.join(data, APP_JSON))
const state = (await stateFile.json().catch(() => ({}))) as {
+20
View File
@@ -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
})
+6 -3
View File
@@ -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 },
{
+15 -8
View File
@@ -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
}
+5 -2
View File
@@ -116,14 +116,17 @@ export namespace Ripgrep {
export async function files(input: {
cwd: string
query?: string
glob?: string
limit?: number
}) {
const commands = [`${await filepath()} --files --hidden --glob='!.git/*'`]
const commands = [
`${await filepath()} --files --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
]
if (input.query)
commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
const result = await $`${{ raw: joined }}`.cwd(input.cwd).text()
const result = await $`${{ raw: joined }}`.cwd(input.cwd).nothrow().text()
return result.split("\n").filter(Boolean)
}
}
+3 -1
View File
@@ -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}`)
}
@@ -0,0 +1,4 @@
export async function data() {
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
return json
}
+6 -6
View File
@@ -2,6 +2,7 @@ import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { z } from "zod"
import { data } from "./models-macro" with { type: "macro" }
export namespace ModelsDev {
const log = Log.create({ service: "models.dev" })
@@ -54,16 +55,15 @@ export namespace ModelsDev {
refresh()
return result as Record<string, Provider>
}
await refresh()
return get()
refresh()
const json = await data()
return JSON.parse(json) as Record<string, Provider>
}
async function refresh() {
const file = Bun.file(filepath)
log.info("refreshing")
const result = await fetch("https://models.dev/api.json")
if (!result.ok)
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
await Bun.write(file, result)
const result = await fetch("https://models.dev/api.json").catch(() => {})
if (result && result.ok) await Bun.write(file, result)
}
}
+19 -10
View File
@@ -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"]
+3 -21
View File
@@ -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]
: [],
)
+5 -2
View File
@@ -3,6 +3,7 @@ import path from "path"
import { Tool } from "./tool"
import { App } from "../app/app"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../external/ripgrep"
export const GlobTool = Tool.define({
id: "glob",
@@ -24,10 +25,12 @@ export const GlobTool = Tool.define({
: path.resolve(app.path.cwd, search)
const limit = 100
const glob = new Bun.Glob(params.pattern)
const files = []
let truncated = false
for await (const file of glob.scan({ cwd: search, dot: true })) {
for (const file of await Ripgrep.files({
cwd: search,
glob: params.pattern,
})) {
if (files.length >= limit) {
truncated = true
break
+36 -16
View File
@@ -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,
}
}
+44 -1
View File
@@ -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),