Skip to content
82 changes: 74 additions & 8 deletions packages/app/src/components/prompt-input-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
Expand Down Expand Up @@ -43,12 +44,39 @@ export type PromptInputV2ComposerProps = {
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"]
readonly session: PromptInputProps["controls"]["session"]
}

export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
const permission = usePermission()
const sdk = useSDK()

const autoAcceptActive = createMemo(() => {
const sessionID = props.controller.session.id
if (!sessionID) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(sessionID, sdk().directory)
})

const toggleAutoAccept = () => {
const sessionID = props.controller.session.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
else permission.toggleAutoAcceptDirectory(sdk().directory)

const active = sessionID
? permission.isAutoAccepting(sessionID, sdk().directory)
: permission.isAutoAcceptingDirectory(sdk().directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
}

useCommands(props)
useEditHandler(props)
Expand All @@ -73,6 +101,16 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
}
/>
}
controls={
<PromptInputV2PermissionControl
active={autoAcceptActive()}
title={language.t(
autoAcceptActive() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
)}
keybind={command.keybindParts("permissions.autoaccept")}
onClick={toggleAutoAccept}
/>
}
/>
</div>
)
Expand Down Expand Up @@ -150,7 +188,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
const comments = useComments()
const dialog = useDialog()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const prompt = props.state ?? usePrompt()
Expand Down Expand Up @@ -251,17 +288,11 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
)
}

const accepting = createMemo(() => {
const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
})
const submission = createPromptSubmit({
prompt,
info,
imageAttachments: attachments,
commentCount,
autoAccept: accepting,
mode,
working,
editor: () => editor,
Expand Down Expand Up @@ -464,10 +495,45 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
},
},
})
Object.defineProperty(controller, "model", { get: () => props.controls.model })
Object.defineProperties(controller, {
model: { get: () => props.controls.model },
session: { get: () => props.controls.session },
})
return controller as PromptInputV2ComposerController
}

function PromptInputV2PermissionControl(props: {
active: boolean
title: string
keybind: string[]
onClick: () => void
}) {
return (
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.title}
<KeybindV2 keys={props.keybind} variant="neutral" />
</>
}
>
<IconButtonV2
data-action="prompt-permissions"
type="button"
icon={<Icon name="shield" />}
variant="ghost-muted"
size="large"
classList={{ "bg-surface-success-base text-icon-success-base": props.active }}
onClick={props.onClick}
aria-label={props.title}
aria-pressed={props.active}
/>
</TooltipV2>
)
}

function PromptInputV2ModelControl(props: {
loading: boolean
paid: boolean
Expand Down
61 changes: 56 additions & 5 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1192,20 +1192,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
// Check provider variants directly: `variants` also includes the UI-only default option.
const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
const accepting = createMemo(() => {
const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
const autoAcceptActive = createMemo(() => {
const sessionID = props.controls.session.id
if (!sessionID) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(sessionID, sdk().directory)
})

const toggleAutoAccept = () => {
const sessionID = props.controls.session.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
else permission.toggleAutoAcceptDirectory(sdk().directory)

const active = sessionID
? permission.isAutoAccepting(sessionID, sdk().directory)
: permission.isAutoAcceptingDirectory(sdk().directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
}

const { abort, handleSubmit } =
props.submission ??
createPromptSubmit({
prompt,
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
Expand Down Expand Up @@ -1780,6 +1797,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
</Show>
</Show>
<div
data-component="prompt-permissions-control"
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t(
autoAcceptActive()
? "command.permissions.autoaccept.disable"
: "command.permissions.autoaccept.enable",
)}
keybind={command.keybind("permissions.autoaccept")}
>
<IconButton
data-action="prompt-permissions"
type="button"
icon="shield"
variant="ghost"
class="size-8"
classList={{
"text-icon-success-base hover:bg-surface-success-base": autoAcceptActive(),
}}
style={control()}
onClick={toggleAutoAccept}
aria-label={language.t(
autoAcceptActive()
? "command.permissions.autoaccept.disable"
: "command.permissions.autoaccept.enable",
)}
aria-pressed={autoAcceptActive()}
/>
</TooltipKeybind>
</div>
</div>
</div>
</div>
Expand Down
20 changes: 12 additions & 8 deletions packages/app/src/components/prompt-input/submit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ let params: { id?: string } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
let autoAcceptDirectory = false
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined

Expand Down Expand Up @@ -138,7 +139,13 @@ beforeAll(async () => {
enabledAutoAccept.push({ server, sessionID, directory })
},
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
return {
usePermission: () => ({
currentServerState: () => state(permissionServer),
isAutoAccepting: () => false,
isAutoAcceptingDirectory: () => autoAcceptDirectory,
}),
}
})

mock.module("@/context/server", () => ({
Expand Down Expand Up @@ -262,6 +269,7 @@ beforeEach(() => {
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
autoAcceptDirectory = false
permissionServer = "server-a"
createSessionGate = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
Expand All @@ -274,7 +282,6 @@ describe("prompt submit worktree selection", () => {
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
Expand Down Expand Up @@ -307,12 +314,13 @@ describe("prompt submit worktree selection", () => {
})

test("applies auto-accept to newly created sessions", async () => {
autoAcceptDirectory = true

const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
Expand All @@ -335,6 +343,7 @@ describe("prompt submit worktree selection", () => {
})

test("keeps auto-accept bound to the submission server", async () => {
autoAcceptDirectory = true
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
Expand All @@ -344,7 +353,6 @@ describe("prompt submit worktree selection", () => {
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
Expand Down Expand Up @@ -374,7 +382,6 @@ describe("prompt submit worktree selection", () => {
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
Expand Down Expand Up @@ -403,7 +410,6 @@ describe("prompt submit worktree selection", () => {
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
Expand Down Expand Up @@ -440,7 +446,6 @@ describe("prompt submit worktree selection", () => {
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
Expand Down Expand Up @@ -468,7 +473,6 @@ describe("prompt submit worktree selection", () => {
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
Expand Down
3 changes: 1 addition & 2 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ type PromptSubmitInput = {
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
autoAccept: Accessor<boolean>
mode: Accessor<"normal" | "shell">
working: Accessor<boolean>
editor: () => HTMLDivElement | undefined
Expand Down Expand Up @@ -317,7 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const projectDirectory = sdk().directory
const permissionState = permission.currentServerState()
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const shouldAutoAccept = isNewSession && permission.isAutoAcceptingDirectory(projectDirectory)
const worktreeSelection = input.newSessionWorktree?.() || "main"

let sessionDirectory = projectDirectory
Expand Down
16 changes: 10 additions & 6 deletions packages/app/src/context/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "followup", "steer")
})

return {
ready,
get current() {
return store
},
general: {
function createGeneralSettings() {
return {
autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
setAutoSave(value: boolean) {
setStore("general", "autoSave", value)
Expand Down Expand Up @@ -425,7 +421,15 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
dismissTabsToast() {
setStore("general", "shouldDisplayTabsToast", false)
},
}
}

return {
ready,
get current() {
return store
},
general: createGeneralSettings(),
visibility: {
fileTree: visible(showFileTree),
search: visible(showSearch),
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/pages/layout-new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
import { useDesktopDeepLinks } from "./layout/use-desktop-deep-links"

export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
useDesktopDeepLinks()

createEffect(() => setV2Toast(true))

Expand Down
Loading
Loading