Compare commits

...

11 Commits

Author SHA1 Message Date
Shantur Rathore
4f6c8523c0 Merge pull request #174 from NeuralNomadsAI/codenomad/issue-173
Docs: link server CLI docs and list flags/env vars
2026-02-15 15:30:33 +00:00
Shantur Rathore
8c24a7daf3 docs: reorganize server and dev release docs 2026-02-15 15:29:06 +00:00
Shantur Rathore
682937e945 docs(server): improve CLI flag/env var docs
Make server usage easier to discover from the root README, add local install/run instructions, and document additional CLI flags/env vars for UI and logging.
2026-02-15 15:21:09 +00:00
Shantur Rathore
35ff359c0f Merge pull request #170 from NeuralNomadsAI/codenomad/issue-153
Fix: hide keyboard shortcut hints in WebUI + add toggle
2026-02-15 09:24:30 +00:00
Shantur Rathore
c7195469bd fix(ui): add keyboard shortcut hints toggle
Hide shortcut hints in WebUI and allow toggling in native desktop apps.
2026-02-14 00:02:56 +00:00
Shantur Rathore
e9f281a69d Merge pull request #168 from NeuralNomadsAI/codenomad/issue-166
fix(ui): hide keyboard hints on phone layout
2026-02-13 10:15:53 +00:00
Shantur Rathore
36baac06b8 fix(ui): hide kbd hints on non-desktop 2026-02-13 10:02:15 +00:00
Shantur Rathore
3678214e69 fix(ui): hide keyboard hints on non-desktop 2026-02-13 09:54:46 +00:00
Shantur Rathore
338e3d9d38 fix(ui): hide keyboard hints on phone layout 2026-02-13 09:21:24 +00:00
Shantur Rathore
0c0f397db0 Merge pull request #164 from NeuralNomadsAI/codenomad/issue-159
fix(ui): keep prompt attachments in sync
2026-02-13 08:05:05 +00:00
Shantur Rathore
da70cc9944 fix(ui): keep prompt attachments in sync 2026-02-13 00:51:42 +00:00
31 changed files with 346 additions and 127 deletions

View File

@@ -44,19 +44,22 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
npx @neuralnomads/codenomad --launch
```
For dev version
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
- [packages/server/README.md](packages/server/README.md)
To see all available options:
```bash
npx @neuralnomads/codenomad --help
```
### 🧪 Dev Releases
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
```bash
npx @neuralnomads/codenomad-dev --launch
```
Dev builds are published as GitHub pre-releases:
https://github.com/shantur/CodeNomad/releases
Dev releases are bleeding-edge builds, generated automatically every time a new commit is pushed to the `dev` branch.
This command starts the server and opens the web client in your default browser.
## Highlights
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.

View File

@@ -31,6 +31,12 @@ You can run CodeNomad directly without installing it:
npx @neuralnomads/codenomad --launch
```
To list all CLI options:
```sh
npx @neuralnomads/codenomad --help
```
On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells)
@@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad
codenomad --launch
```
### Install Locally (per-project)
If you prefer to install CodeNomad into a project and run the local binary:
```sh
npm install @neuralnomads/codenomad
npx codenomad --launch
```
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
### Common Flags
You can configure the server using flags or environment variables:
@@ -63,10 +79,30 @@ You can configure the server using flags or environment variables:
| `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
### Dev Releases (Advanced)
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
```sh
npx @neuralnomads/codenomad-dev --launch
```
These environment variables control how CodeNomad checks for dev updates:
| Env Variable | Description |
|-------------|-------------|
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
### HTTP vs HTTPS

View File

@@ -60,6 +60,7 @@ const App: Component = () => {
preferences,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
@@ -80,6 +81,13 @@ const App: Component = () => {
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
createEffect(() => {
if (typeof document === "undefined") return
const shouldShow =
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
})
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
@@ -293,6 +301,7 @@ const App: Component = () => {
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,

View File

@@ -112,6 +112,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const groupedCommandList = () => processedCommands().groups
const orderedCommands = () => processedCommands().ordered
const isCommandDisabled = (command: Command) => {
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
}
const selectedIndex = createMemo(() => {
const ordered = orderedCommands()
if (ordered.length === 0) return -1
@@ -138,10 +142,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
}
return
}
const currentId = selectedCommandId()
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
setSelectedCommandId(ordered[0].id)
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
}
})
@@ -195,12 +200,14 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
if (index < 0 || index >= ordered.length) return
const command = ordered[index]
if (!command) return
if (isCommandDisabled(command)) return
props.onExecute(command)
props.onClose()
}
}
function handleCommandClick(command: Command) {
if (isCommandDisabled(command)) return
props.onExecute(command)
props.onClose()
}
@@ -265,11 +272,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
<For each={group.commands}>
{(command, localIndex) => {
const commandIndex = group.startIndex + localIndex()
const disabled = isCommandDisabled(command)
return (
<button
type="button"
data-command-index={commandIndex}
onClick={() => handleCommandClick(command)}
disabled={disabled}
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
onPointerMove={(event) => {
if (event.movementX === 0 && event.movementY === 0) return

View File

@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
</Show>
</div>
<div class="panel-footer">
<div class="panel-footer keyboard-hints">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>

View File

@@ -548,7 +548,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
</div>
@@ -573,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
<div class="panel panel-footer shrink-0 hidden sm:block">
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
@@ -591,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<Kbd shortcut="cmd+n" class="kbd-hint" />
<span>{t("folderSelection.hints.browse")}</span>
</div>
</div>

View File

@@ -3,10 +3,15 @@ import { Component, JSX } from "solid-js"
interface HintRowProps {
children: JSX.Element
class?: string
ariaHidden?: boolean
}
const HintRow: Component<HintRowProps> = (props) => {
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
return (
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
{props.children}
</span>
)
}
export default HintRow

View File

@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
)}
<span>{t("instanceWelcome.new.createButton")}</span>
</div>
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
</button>
</div>
</div>
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div>
</Show>
<div class="panel-footer hidden sm:block">
<div class="panel-footer hidden sm:block keyboard-hints">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">

View File

@@ -633,7 +633,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
{t("instanceShell.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
@@ -730,7 +730,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div>
<div class="session-toolbar-right flex-1 flex items-center gap-3">
<span class="connection-status-shortcut-hint">
<span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" />
</span>

View File

@@ -1,7 +1,7 @@
import { Show, type Accessor, type Component } from "solid-js"
import type { SessionThread } from "../../../stores/session-state"
import type { Session } from "../../../types/session"
import type { KeyboardShortcut } from "../../../lib/keyboard-registry"
import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry"
import type { DrawerViewState } from "./types"
import { PlusSquare, Search } from "lucide-solid"
@@ -13,7 +13,6 @@ import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
import SessionList from "../../session-list"
import KeyboardHint from "../../keyboard-hint"
import Kbd from "../../kbd"
import WorktreeSelector from "../../worktree-selector"
import AgentSelector from "../../agent-selector"
import ModelSelector from "../../model-selector"
@@ -166,11 +165,17 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
<div class="session-sidebar-selector-hints" aria-hidden="true">
<Kbd shortcut="cmd+shift+a" />
<Kbd shortcut="cmd+shift+m" />
<Kbd shortcut="cmd+shift+t" />
</div>
<KeyboardHint
class="session-sidebar-selector-hints"
ariaHidden={true}
shortcuts={[
keyboardRegistry.get("open-agent-selector"),
keyboardRegistry.get("focus-model"),
keyboardRegistry.get("focus-variant"),
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
separator=" "
showDescription={false}
/>
</div>
</>
)}

View File

@@ -1,4 +1,5 @@
import { Component, JSX, For } from "solid-js"
import useMediaQuery from "@suid/material/useMediaQuery"
import { isMac } from "../lib/keyboard-utils"
interface KbdProps {
@@ -27,6 +28,9 @@ const SPECIAL_KEY_LABELS: Record<string, string> = {
}
const Kbd: Component<KbdProps> = (props) => {
const desktopQuery = useMediaQuery("(min-width: 1280px)")
if (!desktopQuery()) return null
const parts = () => {
if (props.children) return [{ text: props.children, isModifier: false }]
if (!props.shortcut) return []

View File

@@ -1,14 +1,20 @@
import { Component, For } from "solid-js"
import { formatShortcut, isMac } from "../lib/keyboard-utils"
import useMediaQuery from "@suid/material/useMediaQuery"
import type { KeyboardShortcut } from "../lib/keyboard-registry"
import Kbd from "./kbd"
import HintRow from "./hint-row"
const KeyboardHint: Component<{
shortcuts: KeyboardShortcut[]
separator?: string
separator?: string | null
showDescription?: boolean
class?: string
ariaHidden?: boolean
}> = (props) => {
// Centralize layout gating here so call sites don't need to.
// We only show keyboard hint UI on desktop layouts.
const desktopQuery = useMediaQuery("(min-width: 1280px)")
function buildShortcutString(shortcut: KeyboardShortcut): string {
const parts: string[] = []
@@ -26,12 +32,14 @@ const KeyboardHint: Component<{
return parts.join("+")
}
if (!desktopQuery()) return null
return (
<HintRow>
<HintRow class={props.class} ariaHidden={props.ariaHidden}>
<For each={props.shortcuts}>
{(shortcut, i) => (
<>
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
{i() > 0 && props.separator !== null && <span class="mx-1">{props.separator ?? "•"}</span>}
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
<Kbd shortcut={buildShortcutString(shortcut)} />
</>

View File

@@ -62,7 +62,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
{t("messageListHeader.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
</span>
</div>
</div>

View File

@@ -867,7 +867,7 @@ export default function MessageSection(props: MessageSectionProps) {
<ul>
<li>
<span>{t("messageSection.empty.tips.commandPalette")}</span>
<Kbd shortcut="cmd+shift+p" class="ml-2" />
<Kbd shortcut="cmd+shift+p" class="ml-2 kbd-hint" />
</li>
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
<li>

View File

@@ -1,8 +1,8 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js"
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button"
import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments"
import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances"
@@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) {
handleDrop,
syncAttachmentCounters,
handleExpandTextAttachment,
handleRemoveAttachment,
} = usePromptAttachments({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
@@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) {
if (!attachment) return
handleExpandTextAttachment(attachment)
},
removeAttachment: (attachmentId: string) => {
handleRemoveAttachment(attachmentId)
},
setPromptText: (text: string, opts?: { focus?: boolean }) => {
const textarea = textareaRef
if (textarea) {
@@ -166,10 +170,7 @@ export default function PromptInput(props: PromptInputProps) {
setAtPosition(null)
setSearchQuery("")
const instanceId = props.instanceId
const sessionId = props.sessionId
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
syncAttachmentCounters(prompt(), currentAttachments)
syncAttachmentCounters(prompt())
},
{ defer: true },
),
@@ -238,10 +239,10 @@ export default function PromptInput(props: PromptInputProps) {
// Ignore attachments for slash commands, but keep them for next prompt.
if (!isKnownSlashCommand) {
clearAttachments(props.instanceId, props.sessionId)
syncAttachmentCounters("", [])
syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>())
} else {
syncAttachmentCounters("", currentAttachments)
syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>())
}
@@ -479,7 +480,7 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={

View File

@@ -1,5 +1,3 @@
import type { Attachment } from "../../types/attachment"
export function formatPastedPlaceholder(value: string | number) {
return `[pasted #${value}]`
}
@@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) {
}
export function createPastedPlaceholderRegex() {
return /\[pasted #(\d+)\]/g
return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi
}
export function createImagePlaceholderRegex() {
return /\[Image #(\d+)\]/g
return /\[\s*Image\s*#\s*(\d+)\s*\]/gi
}
export function createMentionRegex() {
return /@(\S+)/g
}
export const pastedDisplayCounterRegex = /pasted #(\d+)/
export const imageDisplayCounterRegex = /Image #(\d+)/
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
export const pastedDisplayCounterRegex = /pasted #(\d+)/i
export const imageDisplayCounterRegex = /Image #(\d+)/i
export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i
export function parseCounter(value: string) {
const parsed = Number.parseInt(value, 10)
return Number.isNaN(parsed) ? null : parsed
}
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
export function findHighestAttachmentCounters(currentPrompt: string) {
let highestPaste = 0
let highestImage = 0
@@ -40,27 +38,6 @@ export function findHighestAttachmentCounters(currentPrompt: string, sessionAtta
}
}
for (const attachment of sessionAttachments) {
if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
const parsed = parseCounter(placeholderMatch[1])
if (parsed !== null) {
highestPaste = Math.max(highestPaste, parsed)
}
}
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
if (imageMatch) {
const parsed = parseCounter(imageMatch[1])
if (parsed !== null) {
highestImage = Math.max(highestImage, parsed)
}
}
}
}
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
const parsed = parseCounter(match[1])
if (parsed !== null) {

View File

@@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void
expandTextAttachment(attachmentId: string): void
removeAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void
focus(): void
}

View File

@@ -1,4 +1,4 @@
import { createSignal, type Accessor } from "solid-js"
import { createEffect, createSignal, type Accessor } from "solid-js"
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
import type { Attachment } from "../../types/attachment"
@@ -7,6 +7,7 @@ import {
findHighestAttachmentCounters,
formatImagePlaceholder,
formatPastedPlaceholder,
imageDisplayCounterRegex,
pastedDisplayCounterRegex,
} from "./attachmentPlaceholders"
@@ -23,7 +24,7 @@ type PromptAttachments = {
attachments: Accessor<Attachment[]>
pasteCount: Accessor<number>
imageCount: Accessor<number>
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
syncAttachmentCounters: (promptText: string) => void
handlePaste: (e: ClipboardEvent) => Promise<void>
isDragging: Accessor<boolean>
@@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0)
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
function syncAttachmentCounters(currentPrompt: string) {
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt)
setPasteCount(highestPaste)
setImageCount(highestImage)
}
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) {
const next = currentPrompt.replace(tokenRegex, "")
if (next === currentPrompt) return currentPrompt
return next
.replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.trim()
}
const createLooseImagePlaceholderRegex = (counter: string | number) =>
new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i")
const createLoosePastedPlaceholderRegex = (counter: string | number) =>
new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i")
// Keep placeholder-backed attachments in sync with prompt text.
// If the placeholder token disappears from the prompt, the attachment should disappear too.
createEffect(() => {
const currentPrompt = options.prompt()
const currentAttachments = attachments()
const toRemove: string[] = []
for (const attachment of currentAttachments) {
if (attachment.source.type === "text") {
const match = attachment.display.match(pastedDisplayCounterRegex)
if (!match) continue
const counter = match[1]
if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) {
toRemove.push(attachment.id)
}
continue
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const match =
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
if (!match) continue
const counter = match[1]
if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) {
toRemove.push(attachment.id)
}
}
}
for (const attachmentId of toRemove) {
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
}
})
function handleRemoveAttachment(attachmentId: string) {
const currentAttachments = attachments()
const attachment = currentAttachments.find((a) => a.id === attachmentId)
// Always remove from store.
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
if (attachment) {
const currentPrompt = options.prompt()
let newPrompt = currentPrompt
if (!attachment) return
if (attachment.source.type === "file") {
if (attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
if (imageMatch) {
const placeholder = formatImagePlaceholder(imageMatch[1])
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
}
} else {
const filename = attachment.filename
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
const currentPrompt = options.prompt()
let nextPrompt = currentPrompt
if (attachment.source.type === "file") {
if (attachment.mediaType.startsWith("image/")) {
const imageMatch =
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
if (imageMatch) {
nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1]))
}
} else if (attachment.source.type === "agent") {
const agentName = attachment.filename
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
} else if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
const placeholder = formatPastedPlaceholder(placeholderMatch[1])
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
} else {
// For file mentions we insert `@<path>`, but the chip might display `@<filename>`.
const candidates = [attachment.source.path, attachment.filename]
for (const candidate of candidates) {
if (!candidate) continue
const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i")
nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex)
}
}
} else if (attachment.source.type === "agent") {
const agentName = attachment.filename
const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i")
nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex)
} else if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1]))
}
}
options.setPrompt(newPrompt)
if (nextPrompt !== currentPrompt) {
options.setPrompt(nextPrompt)
}
}
@@ -143,13 +205,32 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const blob = item.getAsFile()
if (!blob) continue
const count = imageCount() + 1
const { highestImage } = findHighestAttachmentCounters(options.prompt())
const count = highestImage + 1
setImageCount(count)
const placeholder = formatImagePlaceholder(count)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
} else {
options.setPrompt(options.prompt() + placeholder)
}
const reader = new FileReader()
reader.onload = () => {
const base64Data = (reader.result as string).split(",")[1]
const display = formatImagePlaceholder(count)
const filename = `image-${count}.png`
const attachment = createFileAttachment(
@@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
options.instanceFolder(),
)
attachment.url = `data:image/png;base64,${base64Data}`
attachment.display = display
attachment.display = placeholder
addAttachment(options.instanceId(), options.sessionId(), attachment)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const placeholder = formatImagePlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
}
}
reader.readAsDataURL(blob)
@@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
if (isLongPaste) {
e.preventDefault()
const count = pasteCount() + 1
const { highestPaste } = findHighestAttachmentCounters(options.prompt())
const count = highestPaste + 1
setPasteCount(count)
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
@@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const filename = `paste-${count}.txt`
const attachment = createTextAttachment(pastedText, display, filename)
addAttachment(options.instanceId(), options.sessionId(), attachment)
const placeholder = formatPastedPlaceholder(count)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const placeholder = formatPastedPlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
@@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
} else {
options.setPrompt(options.prompt() + placeholder)
}
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
}

View File

@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
</span>
</Show>
</div>
<kbd class="kbd ml-2">
<kbd class="kbd ml-2 kbd-hint">
Cmd+Enter
</kbd>
</button>

View File

@@ -299,13 +299,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/>
<Show when={attachments().length > 0}>
<PromptAttachmentsBar
attachments={attachments()}
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<Show when={attachments().length > 0}>
<PromptAttachmentsBar
attachments={attachments()}
onRemoveAttachment={(attachmentId) => {
if (promptInputApi) {
promptInputApi.removeAttachment(attachmentId)
return
}
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<PromptInput
instanceId={props.instanceId}

View File

@@ -18,6 +18,7 @@ export interface Command {
description: Resolvable<string>
keywords?: Resolvable<string[]>
shortcut?: KeyboardShortcut
disabled?: Resolvable<boolean>
action: () => void | Promise<void>
category?: Resolvable<string>
}

View File

@@ -14,6 +14,7 @@ import { getLogger } from "../logger"
import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
const log = getLogger("actions")
@@ -28,6 +29,7 @@ function splitKeywords(key: string): string[] {
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
toggleKeyboardShortcutHints: () => void
toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
@@ -454,6 +456,26 @@ export function useCommands(options: UseCommandsOptions) {
action: options.toggleShowTimelineTools,
})
commandRegistry.register({
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
options.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: options.toggleKeyboardShortcutHints,
})
commandRegistry.register({
id: "thinking-default-visibility",
label: () => {

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
"commands.keyboardShortcutHints.label.show": "Show Keyboard Shortcut Hints",
"commands.keyboardShortcutHints.label.hide": "Hide Keyboard Shortcut Hints",
"commands.keyboardShortcutHints.description": "Show or hide keyboard shortcut hints across the UI",
"commands.keyboardShortcutHints.description.disabledWeb": "Disabled in WebUI (shortcut hints are always hidden)",
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, hints",
"commands.common.expanded": "Expanded",
"commands.common.collapsed": "Collapsed",
"commands.common.visible": "Visible",

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes",
"commands.timelineToolCalls.keywords": "línea de tiempo, herramienta, alternar",
"commands.keyboardShortcutHints.label.show": "Mostrar atajos de teclado",
"commands.keyboardShortcutHints.label.hide": "Ocultar atajos de teclado",
"commands.keyboardShortcutHints.description": "Mostrar u ocultar sugerencias de atajos de teclado en la interfaz",
"commands.keyboardShortcutHints.description.disabledWeb": "Desactivado en WebUI (los atajos siempre se ocultan)",
"commands.keyboardShortcutHints.keywords": "atajo, atajos, teclado, keybind, pistas",
"commands.common.expanded": "Expandido",
"commands.common.collapsed": "Colapsado",
"commands.common.visible": "Visible",

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages",
"commands.timelineToolCalls.keywords": "timeline, outil, basculer",
"commands.keyboardShortcutHints.label.show": "Afficher les raccourcis clavier",
"commands.keyboardShortcutHints.label.hide": "Masquer les raccourcis clavier",
"commands.keyboardShortcutHints.description": "Afficher ou masquer les indices de raccourcis clavier dans l'interface",
"commands.keyboardShortcutHints.description.disabledWeb": "Désactivé en WebUI (les raccourcis sont toujours masqués)",
"commands.keyboardShortcutHints.keywords": "raccourci, raccourcis, clavier, keybind, indices",
"commands.common.expanded": "Développé",
"commands.common.collapsed": "Réduit",
"commands.common.visible": "Visible",

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
"commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle",
"commands.keyboardShortcutHints.label.show": "キーボードショートカットのヒントを表示",
"commands.keyboardShortcutHints.label.hide": "キーボードショートカットのヒントを非表示",
"commands.keyboardShortcutHints.description": "UI 全体のキーボードショートカットヒントを表示/非表示",
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI では無効(ヒントは常に非表示)",
"commands.keyboardShortcutHints.keywords": "ショートカット, キーボード, ヒント, shortcuts, keyboard, hints",
"commands.common.expanded": "展開",
"commands.common.collapsed": "折りたたみ",
"commands.common.visible": "表示",

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
"commands.timelineToolCalls.keywords": "таймлайн, tool, переключить",
"commands.keyboardShortcutHints.label.show": "Показать подсказки сочетаний",
"commands.keyboardShortcutHints.label.hide": "Скрыть подсказки сочетаний",
"commands.keyboardShortcutHints.description": "Показать или скрыть подсказки сочетаний клавиш в интерфейсе",
"commands.keyboardShortcutHints.description.disabledWeb": "Отключено в WebUI (подсказки всегда скрыты)",
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, подсказки",
"commands.common.expanded": "Развернуто",
"commands.common.collapsed": "Свернуто",
"commands.common.visible": "Видимо",

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
"commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换",
"commands.keyboardShortcutHints.label.show": "显示键盘快捷键提示",
"commands.keyboardShortcutHints.label.hide": "隐藏键盘快捷键提示",
"commands.keyboardShortcutHints.description": "显示或隐藏界面中的键盘快捷键提示",
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI 中已禁用(提示始终隐藏)",
"commands.keyboardShortcutHints.keywords": "shortcuts, keyboard, hints, 快捷键, 键盘, 提示",
"commands.common.expanded": "展开",
"commands.common.collapsed": "折叠",
"commands.common.visible": "可见",

View File

@@ -34,6 +34,7 @@ export type ListeningMode = "local" | "all"
export interface Preferences {
showThinkingBlocks: boolean
showKeyboardShortcutHints: boolean
thinkingBlocksExpansion: ExpansionPreference
showTimelineTools: boolean
promptSubmitOnEnter: boolean
@@ -78,6 +79,7 @@ const MAX_FAVORITE_MODELS = 50
const defaultPreferences: Preferences = {
showThinkingBlocks: false,
showKeyboardShortcutHints: true,
thinkingBlocksExpansion: "expanded",
showTimelineTools: true,
promptSubmitOnEnter: false,
@@ -131,6 +133,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
return {
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
showKeyboardShortcutHints: sanitized.showKeyboardShortcutHints ?? defaultPreferences.showKeyboardShortcutHints,
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter,
@@ -393,6 +396,10 @@ function toggleShowThinkingBlocks(): void {
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
}
function toggleKeyboardShortcutHints(): void {
updatePreferences({ showKeyboardShortcutHints: !preferences().showKeyboardShortcutHints })
}
function toggleShowTimelineTools(): void {
updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
}
@@ -511,6 +518,7 @@ interface ConfigContextValue {
setThemePreference: typeof setThemePreference
updateConfig: typeof updateConfig
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
toggleKeyboardShortcutHints: typeof toggleKeyboardShortcutHints
toggleShowTimelineTools: typeof toggleShowTimelineTools
toggleUsageMetrics: typeof toggleUsageMetrics
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
@@ -548,6 +556,7 @@ const configContextValue: ConfigContextValue = {
setThemePreference,
updateConfig,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
@@ -608,6 +617,7 @@ export {
updateConfig,
updatePreferences,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,

View File

@@ -46,6 +46,11 @@
color: var(--text-primary);
}
.modal-item:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
background-color: var(--surface-hover);
}

View File

@@ -153,6 +153,19 @@
@apply opacity-50;
}
/*
Shortcut hints are useful on desktop native apps, but are noisy/irrelevant on
touch-first devices and in WebUI where browser shortcuts often conflict.
*/
html[data-runtime-host="web"] .keyboard-hints,
html[data-runtime-host="web"] .kbd-hint,
html[data-runtime-platform="mobile"] .keyboard-hints,
html[data-runtime-platform="mobile"] .kbd-hint,
html[data-keyboard-hints="hide"] .keyboard-hints,
html[data-keyboard-hints="hide"] .kbd-hint {
display: none !important;
}
/* Truncate from the start (keeps end visible; good for paths) */
.truncate-start {
overflow: hidden;