Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions build-local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# build-local.sh - build opencode for current platform and install as opencode-local.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PKG_DIR="$REPO_ROOT/packages/opencode"
INSTALL_DIR="$HOME/.local/bin"
BINARY_NAME="opencode-local"
SERVICE_NAME="opencode-server"
RESTART_SERVICE=1

usage() {
cat <<EOF
Usage: $0 [--no-restart-service]

Options:
--no-restart-service Install the binary without reloading, restarting, or
simulation-starting the user service.

Environment:
OPENCODE_LOCAL_NO_RESTART=1 has the same effect as --no-restart-service.
EOF
}

while [[ $# -gt 0 ]]; do
case "$1" in
--no-restart-service | --no-restart | --skip-restart)
RESTART_SERVICE=0
shift
;;
-h | --help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done

if [[ "${OPENCODE_LOCAL_NO_RESTART:-}" == "1" || "${OPENCODE_LOCAL_SKIP_RESTART:-}" == "1" ]]; then
RESTART_SERVICE=0
fi

export PATH="$PATH:$REPO_ROOT/node_modules/.bin"

echo "==> Installing dependencies..."
bun install --minimum-release-age 0 --frozen-lockfile

echo "==> Building opencode (native linux-x64, --single)..."
cd "$PKG_DIR"
bun run script/build.ts --single --skip-install

BUILT_BIN="$PKG_DIR/dist/opencode-linux-x64/bin/opencode"
if [[ ! -f "$BUILT_BIN" ]]; then
echo "ERROR: expected binary not found at $BUILT_BIN" >&2
exit 1
fi

echo "==> Smoke test..."
"$BUILT_BIN" --version

echo "==> Installing to $INSTALL_DIR/$BINARY_NAME..."
mkdir -p "$INSTALL_DIR"
cp -f "$BUILT_BIN" "$INSTALL_DIR/$BINARY_NAME"
chmod +x "$INSTALL_DIR/$BINARY_NAME"
echo " installed: $("$INSTALL_DIR/$BINARY_NAME" --version)"

SERVICE_FILE="$HOME/.config/systemd/user/$SERVICE_NAME.service"
if [[ -f "$SERVICE_FILE" ]]; then
echo "==> Updating $SERVICE_FILE..."
sed -i "s|ExecStart=.*opencode |ExecStart=$INSTALL_DIR/$BINARY_NAME |" "$SERVICE_FILE"
echo " ExecStart line: $(grep ExecStart "$SERVICE_FILE")"
else
echo "==> Service file not found at $SERVICE_FILE; skipping service file update."
fi

if [[ "$RESTART_SERVICE" == "0" ]]; then
echo "==> Skipping service reload/restart because --no-restart-service was set."
echo "==> Done."
exit 0
fi

echo "==> Reloading systemd user daemon and restarting $SERVICE_NAME..."
if XDG_RUNTIME_DIR="/run/user/$(id -u)" systemctl --user daemon-reload 2>/dev/null; then
XDG_RUNTIME_DIR="/run/user/$(id -u)" systemctl --user restart "$SERVICE_NAME"
sleep 1
XDG_RUNTIME_DIR="/run/user/$(id -u)" systemctl --user status "$SERVICE_NAME" --no-pager
else
echo " DBUS unavailable - simulating start on a different port to verify binary works..."
TEST_PORT=14097
OPENCODE_SERVER_PASSWORD=test OPENCODE_SERVER_USERNAME=test \
"$INSTALL_DIR/$BINARY_NAME" serve --port "$TEST_PORT" --hostname 127.0.0.1 &
SIM_PID=$!
sleep 2
if kill -0 "$SIM_PID" 2>/dev/null; then
echo " OK: binary started successfully on port $TEST_PORT (pid $SIM_PID)"
kill "$SIM_PID"
else
echo "ERROR: binary failed to stay alive on test port $TEST_PORT" >&2
exit 1
fi
echo ""
echo " NOTE: run 'systemctl --user restart $SERVICE_NAME' from your desktop session to apply."
fi

echo "==> Done."
42 changes: 42 additions & 0 deletions opencode-local-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# opencode-local-update.sh - build and install the currently checked-out production branch.
# Invoked manually or by opencode-local-build.service. This script pulls the current upstream with --ff-only.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BRANCH_NAME="production"

export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$REPO_ROOT/node_modules/.bin"
export SSH_AUTH_SOCK="/run/user/$UID/ssh-tpm-agent.sock"

cd "$REPO_ROOT"

CURRENT_BRANCH="$(git branch --show-current)"

if [[ "$CURRENT_BRANCH" != "$BRANCH_NAME" ]]; then
echo "ERROR: current branch is '$CURRENT_BRANCH'; checkout '$BRANCH_NAME' before building." >&2
exit 1
fi

if ! UPSTREAM_REF="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)"; then
echo "ERROR: branch '$BRANCH_NAME' has no upstream configured." >&2
exit 1
fi

UPSTREAM_REMOTE="${UPSTREAM_REF%%/*}"
UPSTREAM_BRANCH="${UPSTREAM_REF#*/}"

if [[ "$UPSTREAM_BRANCH" != "$BRANCH_NAME" ]]; then
echo "ERROR: upstream branch is '$UPSTREAM_BRANCH'; expected '$BRANCH_NAME'." >&2
exit 1
fi

echo "==> Pulling $UPSTREAM_REF..."
git pull --ff-only "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH"

HEAD_HASH="$(git rev-parse HEAD)"
HEAD_SHORT="$(git rev-parse --short HEAD)"

echo "==> Building current $BRANCH_NAME checkout at $HEAD_SHORT ($HEAD_HASH)..."
echo "==> Building and reinstalling from $BRANCH_NAME..."
"$REPO_ROOT/build-local.sh" "$@"
124 changes: 82 additions & 42 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ interface PromptInputProps {
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
editingQueueID?: string
onEditingQueueMessageID?: (id: string | undefined) => void
onEditLoaded?: () => void
onCancelQueueEdit?: () => void
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
Expand Down Expand Up @@ -268,6 +271,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
// Per-message queue override toggled by the composer button or Alt+Enter. It
// makes the next submit go to the server prompt queue even
// when the followup setting is not "queue", and resets after each submit.
const [queueMode, setQueueMode] = createSignal(false)
const [localEditingQueueID, setLocalEditingQueueID] = createSignal<string | undefined>()
const editingQueueID = () => props.editingQueueID ?? localEditingQueueID()
const setEditingQueueID = (id: string | undefined) => {
props.onEditingQueueMessageID?.(id)
if (props.onEditingQueueMessageID === undefined) setLocalEditingQueueID(id)
}

const [store, setStore] = createStore<{
popover: "at" | "slash" | null
Expand All @@ -277,7 +290,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
variantOpen: boolean
}>({
popover: null,
historyIndex: -1,
Expand All @@ -286,7 +298,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null,
mode: "normal",
applyingHistory: false,
variantOpen: false,
})
const [picker, setPicker] = createStore({
projectOpen: false,
Expand Down Expand Up @@ -334,6 +345,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
}

// Per-message queue toggle. Only meaningful for existing sessions in normal
// mode (shell commands always run now, and new sessions have nothing to queue
// against). Clicking flips queueMode; the send icon and submit path follow.
const queueToggle = () => (
<Show when={store.mode === "normal" && !!params.id}>
<Tooltip
placement="top"
value={queueMode() ? language.t("prompt.action.sendDirect") : language.t("prompt.action.queue")}
>
<IconButton
data-action="prompt-queue-toggle"
type="button"
icon="bullet-list"
variant={queueMode() ? "secondary" : "ghost"}
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
onClick={() => setQueueMode((value) => !value)}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-pressed={queueMode()}
aria-label={queueMode() ? language.t("prompt.action.sendDirect") : language.t("prompt.action.queue")}
/>
</Tooltip>
</Show>
)

const contextItems = createMemo(() => {
const items = prompt.context.items()
if (store.mode !== "shell") return items
Expand Down Expand Up @@ -1027,6 +1063,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const edit = props.edit
if (!id || !edit) return

setEditingQueueID(edit.id)

for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
}
Expand Down Expand Up @@ -1103,8 +1141,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)

const variants = createMemo(() => ["default", ...local.model.variant.list()])
// Check provider variants directly: `variants` also includes the UI-only default option.
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
const accepting = createMemo(() => {
const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
Expand All @@ -1130,6 +1166,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
queueMode,
resetQueueMode: () => setQueueMode(false),
editingQueueID: () => editingQueueID(),
resetEditingQueueID: () => setEditingQueueID(undefined),
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
Expand Down Expand Up @@ -1179,6 +1219,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}

if (editingQueueID()) {
props.onCancelQueueEdit?.()
event.preventDefault()
event.stopPropagation()
return
}

if (store.mode === "shell") {
setStore("mode", "normal")
event.preventDefault()
Expand Down Expand Up @@ -1277,6 +1324,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}

if (event.altKey && event.key === "Enter" && store.mode !== "shell") {
event.preventDefault()
if (event.repeat) return
setQueueMode(true)
void handleSubmit(event)
return
}

// Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
Expand Down Expand Up @@ -1367,13 +1422,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = async () => {
const conn = server.current
if (!conn) return
const select = (result: string | string[] | null) => {
const directory = Array.isArray(result) ? result[0] : result
if (!directory) return
selectProject(directory)
}
const conn = server.current
if (!conn) return
if (platform.openDirectoryPickerDialog && server.isLocal()) {
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
return
Expand Down Expand Up @@ -1575,47 +1630,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ComposerPickerTrigger state={newProjectTriggerState()} />
</Show>
<ComposerModelControl state={modelControlState()} />
<Show when={store.mode !== "shell" && showVariantControl()}>
<div
data-component="prompt-variant-control"
classList={{
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!local.model.variant.current() && !store.variantOpen,
}}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onOpenChange={(open) => setStore("variantOpen", open)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</div>
{queueToggle()}
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
icon={
stopping()
? "stop"
: queueMode() && store.mode === "normal"
? "bullet-list"
: store.mode === "shell"
? "arrow-undo-down"
: "arrow-up"
}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
Expand Down Expand Up @@ -1752,13 +1783,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>

<div class="flex items-center gap-1 pointer-events-auto">
{queueToggle()}
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
icon={
stopping()
? "stop"
: queueMode() && store.mode === "normal"
? "bullet-list"
: store.mode === "shell"
? "arrow-undo-down"
: "arrow-up"
}
variant="primary"
class="size-8"
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
Expand Down Expand Up @@ -1927,7 +1967,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind>
</Show>
</div>
<Show when={showVariantControl()}>
<Show when={variants().length > 2}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
Expand Down
Loading
Loading