diff --git a/build-local.sh b/build-local.sh new file mode 100755 index 000000000000..f4381fc33e9f --- /dev/null +++ b/build-local.sh @@ -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 <&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." diff --git a/opencode-local-update.sh b/opencode-local-update.sh new file mode 100755 index 000000000000..a5cd9ab21ae3 --- /dev/null +++ b/opencode-local-update.sh @@ -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" "$@" diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8e4913b1f5b0..ffb1b22b6605 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -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 @@ -268,6 +271,16 @@ export const PromptInput: Component = (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() + 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 @@ -277,7 +290,6 @@ export const PromptInput: Component = (props) => { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean - variantOpen: boolean }>({ popover: null, historyIndex: -1, @@ -286,7 +298,6 @@ export const PromptInput: Component = (props) => { draggingType: null, mode: "normal", applyingHistory: false, - variantOpen: false, }) const [picker, setPicker] = createStore({ projectOpen: false, @@ -334,6 +345,31 @@ export const PromptInput: Component = (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 = () => ( + + + 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")} + /> + + + ) + const contextItems = createMemo(() => { const items = prompt.context.items() if (store.mode !== "shell") return items @@ -1027,6 +1063,8 @@ export const PromptInput: Component = (props) => { const edit = props.edit if (!id || !edit) return + setEditingQueueID(edit.id) + for (const item of prompt.context.items()) { prompt.context.remove(item.key) } @@ -1103,8 +1141,6 @@ export const PromptInput: Component = (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) @@ -1130,6 +1166,10 @@ export const PromptInput: Component = (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, @@ -1179,6 +1219,13 @@ export const PromptInput: Component = (props) => { return } + if (editingQueueID()) { + props.onCancelQueueEdit?.() + event.preventDefault() + event.stopPropagation() + return + } + if (store.mode === "shell") { setStore("mode", "normal") event.preventDefault() @@ -1277,6 +1324,14 @@ export const PromptInput: Component = (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() @@ -1367,13 +1422,13 @@ export const PromptInput: Component = (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 @@ -1575,47 +1630,23 @@ export const PromptInput: Component = (props) => { - -
- -