Skip to content

Commit 9697089

Browse files
authored
Merge branch 'dev' into fix/permission-absolute-rules-not-matching-external-files
2 parents d26c1dc + 54ff0a6 commit 9697089

53 files changed

Lines changed: 1946 additions & 161 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 15 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nix/hashes.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"nodeModules": {
3-
"x86_64-linux": "sha256-LN6VLX8bXADSPGt67+YjP1Sy0QdQY+HFPq8iXN5nkck=",
4-
"aarch64-linux": "sha256-3yhpjuCLwi7KCZZvsfabtc50hgznVfD8rcMZ9/JAnSs=",
5-
"aarch64-darwin": "sha256-6spcWVHVBuM0SlWYTFrDvForGbtxFXxeVvJMC9thN+g=",
6-
"x86_64-darwin": "sha256-hnD68Dexq/3EP/Sm3MnL88ezIvLLa6/nVzaV+bRpn9w="
3+
"x86_64-linux": "sha256-qAkjcbc1nJqOnCrNQ0bnsM4WG2ii5K1JWS9ohAYdjus=",
4+
"aarch64-linux": "sha256-Nb+F0e3CvQv+uLzHzj9JKp5hV78mCnlSqFXzgIvgR24=",
5+
"aarch64-darwin": "sha256-BIXALWWrjEZLUKZrY6l6+scjZmKFscFxX26TvWOvXGQ=",
6+
"x86_64-darwin": "sha256-3uaFXl/n6je7AzIfsY1pvt3Ln/U1Oshx3z7ohuVPEs8="
77
}
88
}

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@
3535
"@types/cross-spawn": "6.0.6",
3636
"@octokit/rest": "22.0.0",
3737
"@hono/zod-validator": "0.4.2",
38-
"@opentui/core": "0.2.13",
39-
"@opentui/keymap": "0.2.13",
40-
"@opentui/solid": "0.2.13",
38+
"@opentui/core": "0.2.14",
39+
"@opentui/keymap": "0.2.14",
40+
"@opentui/solid": "0.2.14",
4141
"ulid": "3.0.1",
4242
"@kobalte/core": "0.13.11",
4343
"@types/luxon": "3.7.1",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { usePlatform } from "@/context/platform"
2+
import { Button } from "@opencode-ai/ui/button"
3+
import { useDialog } from "@opencode-ai/ui/context/dialog"
4+
import { Dialog } from "@opencode-ai/ui/dialog"
5+
import { JSX } from "solid-js"
6+
7+
export type DialogGoUpsellProps = {
8+
title: string
9+
description: JSX.Element
10+
link?: string
11+
actionLabel: string
12+
onClose?: (dontShowAgain?: boolean) => void
13+
}
14+
15+
export function DialogUsageExceeded(props: DialogGoUpsellProps) {
16+
const dialog = useDialog()
17+
const platform = usePlatform()
18+
19+
const runAction = () => {
20+
if (props.link) platform.openLink(props.link)
21+
props.onClose?.()
22+
dialog.close()
23+
}
24+
25+
const dismiss = () => {
26+
props.onClose?.(true)
27+
dialog.close()
28+
}
29+
30+
return (
31+
<Dialog title={props.title} description={props.description} fit>
32+
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
33+
<div class="flex justify-end gap-2">
34+
<Button variant="ghost" size="large" onClick={dismiss}>
35+
Don't show again
36+
</Button>
37+
<Button variant="primary" size="large" onClick={runAction}>
38+
{props.actionLabel}
39+
</Button>
40+
</div>
41+
</div>
42+
</Dialog>
43+
)
44+
}

packages/app/src/components/prompt-input.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,6 @@ const EXAMPLES = [
9999
"prompt.example.25",
100100
] as const
101101

102-
const NON_EMPTY_TEXT = /[^\s\u200B]/
103-
104102
export const PromptInput: Component<PromptInputProps> = (props) => {
105103
const sdk = useSDK()
106104
const queryOptions = useQueryOptions()
@@ -860,7 +858,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
860858
? rawParts[0].content
861859
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
862860
const hasNonText = rawParts.some((part) => part.type !== "text")
863-
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
861+
const textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "")
862+
const shouldReset =
863+
textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !hasNonText && images.length === 0
864864

865865
if (shouldReset) {
866866
closePopover()

packages/app/src/pages/session.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import { Persist, persisted } from "@/utils/persist"
6464
import { extractPromptFromParts } from "@/utils/prompt"
6565
import { same } from "@/utils/same"
6666
import { formatServerError } from "@/utils/server-errors"
67+
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
6768

6869
const emptyUserMessages: UserMessage[] = []
6970
type FollowupItem = FollowupDraft & { id: string }
@@ -1645,6 +1646,8 @@ export default function Page() {
16451646
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
16461647
})
16471648

1649+
useUsageExceededDialogs()
1650+
16481651
return (
16491652
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
16501653
{sessionSync() ?? ""}

packages/app/src/pages/session/composer/session-question-dock.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
469469
</>
470470
}
471471
>
472-
<div data-slot="question-text">{question()?.question}</div>
472+
<div data-slot="question-text" class="overflow-auto">
473+
{question()?.question}
474+
</div>
473475
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
474476
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
475477
</Show>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useSDK } from "@/context/sdk"
2+
import { Persist, persisted } from "@/utils/persist"
3+
import { SessionStatus } from "@opencode-ai/sdk/v2"
4+
import { onCleanup } from "solid-js"
5+
import { createStore } from "solid-js/store"
6+
import { useSessionLayout } from "./session-layout"
7+
import { useDialog } from "@opencode-ai/ui/context"
8+
import { DialogUsageExceeded } from "@/components/dialog-usage-exceeded"
9+
import { useI18n } from "@opencode-ai/ui/context"
10+
11+
const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
12+
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
13+
const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
14+
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
15+
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
16+
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
17+
18+
function goUpsellKeys(status: SessionStatus) {
19+
if (status.type !== "retry" || !status.action) return
20+
const { action } = status
21+
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
22+
if (action.reason === "free_tier_limit") {
23+
return {
24+
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
25+
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
26+
} as const
27+
}
28+
if (action.reason === "account_rate_limit") {
29+
return {
30+
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
31+
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
32+
} as const
33+
}
34+
}
35+
36+
export function useUsageExceededDialogs() {
37+
const sdk = useSDK()
38+
const dialog = useDialog()
39+
const { params } = useSessionLayout()
40+
const { t, locale } = useI18n()
41+
const isEnglish = () => locale() === "en"
42+
43+
const [goUpsellState, setGoUpsellState] = persisted(
44+
Persist.global("go-upsell"),
45+
createStore({
46+
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: null as null | number,
47+
[GO_UPSELL_FREE_TIER_DONT_SHOW]: null as null | number,
48+
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null as null | number,
49+
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null as null | number,
50+
}),
51+
)
52+
53+
onCleanup(
54+
sdk.event.on("session.status", (evt) => {
55+
if (evt.properties.sessionID !== params.id) return
56+
if (evt.properties.status.type !== "retry") return
57+
const { action } = evt.properties.status
58+
if (!action) return
59+
if (dialog.active) return
60+
61+
const keys = goUpsellKeys(evt.properties.status)
62+
if (!keys) return
63+
64+
const seen = goUpsellState[keys.lastSeenAt]
65+
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
66+
if (goUpsellState[keys.dontShow]) return
67+
68+
if (action.reason === "free_tier_limit") {
69+
dialog.show(() => (
70+
<DialogUsageExceeded
71+
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
72+
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
73+
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
74+
link={action.link}
75+
onClose={(dontShowAgain) => {
76+
setGoUpsellState(keys.lastSeenAt, Date.now())
77+
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
78+
else {
79+
void import("../../components/dialog-connect-provider").then((x) =>
80+
dialog.show(() => <x.DialogConnectProvider provider="opencode-go" />),
81+
)
82+
}
83+
}}
84+
/>
85+
))
86+
} else if (action.reason === "account_rate_limit") {
87+
dialog.show(() => (
88+
<DialogUsageExceeded
89+
title={isEnglish() ? action.title : t("dialog.usageExceeded.accountRateLimit.title")}
90+
description={isEnglish() ? action.message : t("dialog.usageExceeded.accountRateLimit.description")}
91+
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.accountRateLimit.actionLabel")}
92+
link={action.link}
93+
onClose={(dontShowAgain) => {
94+
setGoUpsellState(keys.lastSeenAt, Date.now())
95+
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
96+
}}
97+
/>
98+
))
99+
}
100+
}),
101+
)
102+
}

packages/desktop/src/main/windows.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const rendererRoot = join(root, "../renderer")
99
const rendererProtocol = "oc"
1010
const rendererHost = "renderer"
1111
const clipboardWritePermission = "clipboard-sanitized-write"
12+
const notificationPermission = "notifications"
13+
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
1214

1315
protocol.registerSchemesAsPrivileged([
1416
{
@@ -109,7 +111,7 @@ export function createMainWindow() {
109111
},
110112
})
111113

112-
allowClipboardWrite(win)
114+
allowRendererPermissions(win)
113115

114116
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
115117
const { requestHeaders } = details
@@ -162,7 +164,7 @@ export function createLoadingWindow() {
162164
},
163165
})
164166

165-
allowClipboardWrite(win)
167+
allowRendererPermissions(win)
166168

167169
loadWindow(win, "loading.html")
168170

@@ -199,16 +201,16 @@ function loadWindow(win: BrowserWindow, html: string) {
199201
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
200202
}
201203

202-
function allowClipboardWrite(win: BrowserWindow) {
204+
function allowRendererPermissions(win: BrowserWindow) {
203205
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
204206
callback(
205-
permission === clipboardWritePermission &&
207+
rendererPermissions.has(permission) &&
206208
isTrustedRendererUrl(details.requestingUrl) &&
207209
webContents.id === win.webContents.id,
208210
)
209211
})
210212
win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
211-
if (permission !== clipboardWritePermission) return false
213+
if (!rendererPermissions.has(permission)) return false
212214
if (webContents && webContents.id !== win.webContents.id) return false
213215
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
214216
})

packages/opencode/src/cli/cmd/run.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ export const RunCommand = effectCmd({
218218
type: "boolean",
219219
describe: "show thinking blocks",
220220
})
221+
.option("replay", {
222+
type: "boolean",
223+
default: false,
224+
describe: "replay visible session history on interactive resume",
225+
})
226+
.option("replay-limit", {
227+
type: "number",
228+
describe: "cap visible interactive replay to the newest N messages",
229+
})
221230
.option("interactive", {
222231
alias: ["i"],
223232
type: "boolean",
@@ -269,6 +278,21 @@ export const RunCommand = effectCmd({
269278
die("--interactive cannot be used with --format json")
270279
}
271280

281+
if (args.replay && !args.interactive) {
282+
die("--replay requires --interactive")
283+
}
284+
285+
if (args["replay-limit"] !== undefined && !args.interactive) {
286+
die("--replay-limit requires --interactive")
287+
}
288+
289+
if (
290+
args["replay-limit"] !== undefined &&
291+
(!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0)
292+
) {
293+
die("--replay-limit must be a positive integer")
294+
}
295+
272296
if (args.interactive && !process.stdout.isTTY) {
273297
die("--interactive requires a TTY stdout")
274298
}
@@ -281,6 +305,8 @@ export const RunCommand = effectCmd({
281305
}
282306
}
283307

308+
const replay = args.replay || args["replay-limit"] !== undefined
309+
284310
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
285311
const directory = (() => {
286312
if (!args.dir) return args.attach ? undefined : root
@@ -786,6 +812,8 @@ export const RunCommand = effectCmd({
786812
sessionID,
787813
sessionTitle: sess.title,
788814
resume: Boolean(args.session || args.continue) && !args.fork,
815+
replay,
816+
replayLimit: args["replay-limit"],
789817
agent,
790818
model,
791819
variant: args.variant,
@@ -821,6 +849,8 @@ export const RunCommand = effectCmd({
821849
agent: args.agent,
822850
model,
823851
variant: args.variant,
852+
replay,
853+
replayLimit: args["replay-limit"],
824854
files,
825855
initialInput,
826856
thinking,

0 commit comments

Comments
 (0)