Compare commits

..

6 Commits

Author SHA1 Message Date
Shantur Rathore
2d1f702597 fix(ui): hide download action for local file attachments
Avoid showing a misleading download icon for file:// attachments in the message stream.
2026-02-14 10:52:15 +00:00
Shantur Rathore
2d93d82611 fix(ui): hide synthetic helper text in user messages
Avoid rendering tool trace/read output parts on user messages while keeping the primary prompt text visible.
2026-02-14 09:53:40 +00:00
Shantur Rathore
4e0f064c3a fix(ui): avoid stripping non-path @mentions 2026-02-14 08:18:35 +00:00
VooDisss
e4e10cc630 fix(ui): replace parent directory when selecting child directory 2026-02-14 07:39:39 +02:00
VooDisss
8f6d4c8b09 fix(ui): improve picker deletion, ESC cancel, and SHIFT+ENTER path handling 2026-02-14 06:53:31 +02:00
Shantur Rathore
1ef01da019 feat(ui): improve picker actions and directory attach 2026-02-13 22:52:42 +00:00
30 changed files with 372 additions and 279 deletions

View File

@@ -44,22 +44,19 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
npx @neuralnomads/codenomad --launch
```
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.
For dev version
```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,12 +31,6 @@ 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)
@@ -50,16 +44,6 @@ 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:
@@ -79,30 +63,10 @@ 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,7 +60,6 @@ const App: Component = () => {
preferences,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
@@ -81,13 +80,6 @@ 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")
@@ -301,7 +293,6 @@ const App: Component = () => {
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
@@ -460,17 +451,25 @@ const App: Component = () => {
<Show when={showFolderSelection()}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="w-full h-full relative">
<button
onClick={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title={t("app.launchError.closeTitle")}
>
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onClose={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
/>
</div>
</div>

View File

@@ -112,10 +112,6 @@ 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
@@ -142,11 +138,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
}
return
}
const currentId = selectedCommandId()
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
setSelectedCommandId(ordered[0].id)
}
})
@@ -200,14 +195,12 @@ 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()
}
@@ -272,13 +265,11 @@ 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 keyboard-hints">
<div class="panel-footer">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>

View File

@@ -1,6 +1,6 @@
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
@@ -23,7 +23,6 @@ interface FolderSelectionViewProps {
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void
onClose?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
@@ -374,18 +373,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<Show when={props.onClose}>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onClose?.()}
aria-label={t("app.launchError.close")}
title={t("app.launchError.closeTitle")}
>
<X class="w-4 h-4" />
<MonitorUp class="w-4 h-4" />
</button>
</Show>
</div>
@@ -560,7 +548,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
@@ -585,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
<div class="panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
@@ -603,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" class="kbd-hint" />
<Kbd shortcut="cmd+n" />
<span>{t("folderSelection.hints.browse")}</span>
</div>
</div>

View File

@@ -8,7 +8,7 @@ interface HintRowProps {
const HintRow: Component<HintRowProps> = (props) => {
return (
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
<span aria-hidden={props.ariaHidden} class={`text-xs text-muted ${props.class || ""}`}>
{props.children}
</span>
)

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-hint" />
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
</button>
</div>
</div>
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div>
</Show>
<div class="panel-footer hidden sm:block keyboard-hints">
<div class="panel-footer hidden sm:block">
<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 kbd-hint">
<span class="connection-status-shortcut-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 kbd-hint">
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>

View File

@@ -1,5 +1,5 @@
import { For, Show, createSignal } from "solid-js"
import { Copy, Split, Trash2, Undo } from "lucide-solid"
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
@@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env"
interface MessageItemProps {
record: MessageRecord
@@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) {
const messageParts = () => props.parts
// User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads).
// We only want to display the primary prompt text for the user message; other synthetic text
// parts should be hidden.
const primaryUserTextPartId = () => {
if (!isUser()) return null
const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined
return typeof firstText?.id === "string" ? firstText.id : null
}
const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
@@ -96,7 +106,8 @@ export default function MessageItem(props: MessageItemProps) {
}
if (url.startsWith("file://")) {
window.open(url, "_blank", "noopener")
// Local filesystem URLs are not reliably downloadable from the message stream.
// We hide the download action for these chips.
return
}
@@ -372,6 +383,7 @@ export default function MessageItem(props: MessageItemProps) {
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
primaryUserTextPartId={primaryUserTextPartId()}
onRendered={props.onContentRendered}
/>
)}
@@ -398,17 +410,20 @@ export default function MessageItem(props: MessageItemProps) {
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={!attachment.url?.startsWith("file://")}>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
title={t("messageItem.attachment.downloadAriaLabel", { name })}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
</Show>
<button
type="button"

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" class="kbd-hint" />
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
</div>

View File

@@ -13,9 +13,12 @@ interface MessagePartProps {
messageType?: "user" | "assistant"
instanceId: string
sessionId: string
// For user messages, keep the primary prompt text visible even when synthetic (optimistic).
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
@@ -28,8 +31,19 @@ interface MessagePartProps {
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
// Keep optimistic user prompts visible; hide synthetic assistant text.
return Boolean((part as any).synthetic) && props.messageType !== "user"
const isSynthetic = Boolean((part as any).synthetic)
if (!isSynthetic) return false
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
if (props.messageType === "user") {
const primaryId = props.primaryUserTextPartId
if (!primaryId) return false
return part.id !== primaryId
}
// Hide synthetic assistant text.
return true
}

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-hint" />
<Kbd shortcut="cmd+shift+p" class="ml-2" />
</li>
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
<li>

View File

@@ -480,7 +480,7 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={

View File

@@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find(
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
)
const attachment = currentAttachments.find((a) => {
if (a.source.type === "agent") {
return a.filename === name
}
if (a.source.type === "file") {
// Match either by filename (basename) or by path (for full paths like @docs/file.txt)
return (
a.filename === name ||
a.source.path === name ||
a.source.path.endsWith("/" + name) ||
a.source.path === name.replace(/\/$/, "")
)
}
if (a.source.type === "text") {
// For text attachments (path-only mentions), match by value
return a.source.value === name || a.source.value.endsWith("/" + name)
}
return false
})
if (attachment) {
e.preventDefault()
@@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
textarea.setSelectionRange(mentionStart, mentionStart)
}, 0)
// Check if there are any @ remaining in the text - if not, close the picker
if (!newText.includes("@") && options.isPickerOpen()) {
options.closePicker()
// Clear ignoredAtPositions since we deleted the entire @mention
// This ensures typing @ again will open the picker
options.setIgnoredAtPositions(new Set())
}
return
}
}

View File

@@ -2,8 +2,9 @@ import { createSignal, type Accessor, type Setter } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { Agent } from "../../types/session"
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
import { addAttachment, getAttachments } from "../../stores/attachments"
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import type { PickerMode } from "./types"
import type { PickerSelectAction } from "../unified-picker"
type PickerItem =
| { type: "agent"; agent: Agent }
@@ -37,7 +38,7 @@ type PromptPickerController = {
setIgnoredAtPositions: Setter<Set<number>>
handleInput: (e: Event) => void
handlePickerSelect: (item: PickerItem) => void
handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void
handlePickerClose: () => void
}
@@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
setAtPosition(null)
}
function handlePickerSelect(item: PickerItem) {
function handlePickerSelect(item: PickerItem, action: PickerSelectAction) {
const textarea = options.getTextarea()
if (item.type === "command") {
// For commands, Tab/Enter/Shift+Enter/click all mean "select".
const name = item.command.name
const currentPrompt = options.prompt()
@@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
}
}, 0)
} else if (item.type === "agent") {
// For agents, Tab/Enter/Shift+Enter/click all mean "select".
const agentName = item.agent.name
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
@@ -163,76 +166,144 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const relativePath = item.file.relativePath ?? displayPath
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
if (isFolder) {
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const newPrompt = before + folderMention + after
options.setPrompt(newPrompt)
setSearchQuery(folderMention)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + 1 + folderMention.length
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
return
}
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPath,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPath,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
if (pos !== null) {
const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => {
if (pos === null) return
const currentPrompt = options.prompt()
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const attachmentText = `@${normalizedPath}`
const newPrompt = before + attachmentText + " " + after
options.setPrompt(newPrompt)
const suffix = opts?.trailingSpace ? " " : ""
const nextPrompt = before + mentionText + suffix + after
options.setPrompt(nextPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + attachmentText.length + 1
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
if (!nextTextarea) return
const nextCursorPos = pos + mentionText.length + suffix.length
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
}, 0)
}
const replaceMentionQueryAfterAt = (value: string) => {
// Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation.
if (pos === null) return
const currentPrompt = options.prompt()
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const nextPrompt = before + value + after
options.setPrompt(nextPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (!nextTextarea) return
const nextCursorPos = pos + 1 + value.length
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
}, 0)
}
const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
const normalizedFolderPath = (() => {
const trimmed = relativePath.replace(/\/+$/, "")
return trimmed.length > 0 ? trimmed : "."
})()
if (isFolder) {
if (action === "tab") {
// TAB on directory: autocomplete directory name and show its contents.
replaceMentionQueryAfterAt(folderMention)
setSearchQuery(folderMention)
return
}
const mentionText = `@${folderMention}`
if (action === "shiftEnter") {
// SHIFT+ENTER on directory: keep @path in prompt (BACKSPACE works), remove @ when sending
replaceMentionToken(mentionText, { trailingSpace: true })
} else {
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
const dirLabel =
normalizedFolderPath === "." ? "/" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory",
)
if (!alreadyAttached) {
// Remove any parent/child directory attachments that overlap with this one
// (e.g., if "docs/" is attached and user selects "docs/screenshots/", replace parent with child)
for (const att of existingAttachments) {
if (
att.source.type === "file" &&
att.source.mime === "inode/directory" &&
(normalizedFolderPath.startsWith(att.source.path + "/") || // new is child of existing
att.source.path.startsWith(normalizedFolderPath + "/")) // new is parent of existing
) {
// Remove the overlapping directory attachment
removeAttachment(options.instanceId(), options.sessionId(), att.id)
}
}
const attachment = createFileAttachment(
normalizedFolderPath,
dirFilename,
"inode/directory",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
replaceMentionToken(mentionText, { trailingSpace: true })
}
} else {
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
if (action === "tab") {
// TAB on file: autocomplete the file path but do not attach.
replaceMentionToken(`@${normalizedPath}`)
setSearchQuery(normalizedPath)
return
}
if (action === "shiftEnter") {
// SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
} else {
// ENTER/click on file: attach file (existing behavior).
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPath,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPath,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
}
}
}
setShowPicker(false)
@@ -245,6 +316,25 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
// Remove the partial @mention text from the textarea when ESC is pressed
const textarea = options.getTextarea()
if (textarea) {
const currentPrompt = options.prompt()
const cursorPos = textarea.selectionStart
// Remove text from @ position to cursor position
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
options.setPrompt(before + after)
// Restore cursor position to where @ was
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
}
}
setShowPicker(false)
setAtPosition(null)

View File

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

View File

@@ -74,10 +74,12 @@ type PickerItem =
| { type: "file"; file: FileItem }
| { type: "command"; command: SDKCommand }
export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter"
interface UnifiedPickerProps {
open: boolean
mode?: "mention" | "command"
onSelect: (item: PickerItem) => void
onSelect: (item: PickerItem, action: PickerSelectAction) => void
onClose: () => void
agents: Agent[]
commands?: SDKCommand[]
@@ -266,6 +268,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery
if (queryChanged) {
// Reset selectedIndex to 0 when query changes to avoid ghost state
// This ensures proper highlighting when navigating back to root or changing queries
setSelectedIndex(0)
resetScrollPosition()
}
if (!isInitialized() || workspaceChanged || queryChanged) {
setIsInitialized(true)
lastWorkspaceId = props.workspaceId
@@ -356,7 +365,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
}
function handleSelect(item: PickerItem) {
props.onSelect(item)
props.onSelect(item, "click")
}
function handleKeyDown(e: KeyboardEvent) {
@@ -379,7 +388,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
e.stopPropagation()
const selected = items[selectedIndex()]
if (selected) {
handleSelect(selected)
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
props.onSelect(selected, action)
}
} else if (e.key === "Escape") {
e.preventDefault()
@@ -443,7 +453,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<div
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
data-picker-selected={isSelected()}
onClick={() => handleSelect({ type: "command", command })}
onClick={() => props.onSelect({ type: "command", command }, "click")}
>
<div class="flex items-start gap-2">
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -479,7 +489,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "agent", agent })}
onClick={() => props.onSelect({ type: "agent", agent }, "click")}
>
<div class="flex items-start gap-2">
<svg
@@ -535,7 +545,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "file", file })}
onClick={() => props.onSelect({ type: "file", file }, "click")}
>
<div class="flex items-center gap-2 text-sm">
<Show

View File

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

View File

@@ -14,7 +14,6 @@ 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")
@@ -29,7 +28,6 @@ function splitKeywords(key: string): string[] {
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
toggleKeyboardShortcutHints: () => void
toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
@@ -456,26 +454,6 @@ 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,12 +97,6 @@ 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,12 +97,6 @@ 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,12 +97,6 @@ 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,12 +97,6 @@ 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,12 +97,6 @@ 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,12 +97,6 @@ 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

@@ -1,12 +1,100 @@
import type { Attachment } from "../types/attachment"
import type { Attachment, FileSource } from "../types/attachment"
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
if (!prompt || !prompt.includes("[pasted #")) {
if (!prompt) {
return prompt
}
// First, strip `@` from path-like mentions that do NOT have a backing file attachment.
// This is intended for SHIFT+ENTER selection where we keep `@path` in the textarea for
// easy deletion, but send `path` to the API.
//
// IMPORTANT: avoid rewriting plain `@mentions` or email addresses.
const fileAttachmentPaths = new Set(
attachments
.filter((a): a is Attachment & { source: FileSource } => a.source.type === "file")
.map((a) => a.source.path),
)
const isPathLike = (value: string) => {
if (!value) return false
if (value.includes("/") || value.includes("\\")) return true
if (value.startsWith("./") || value.startsWith("../")) return true
if (value.startsWith("~")) return true
if (value.endsWith("/")) return true
// Root-level files (no `/`) still commonly have an extension.
const ext = value.split(".").pop()?.toLowerCase()
if (!ext || ext === value.toLowerCase()) return false
// Keep this list intentionally small and code-focused to avoid matching domains like `example.com`.
const allowedExts = new Set([
"ts",
"tsx",
"js",
"jsx",
"mjs",
"cjs",
"json",
"md",
"txt",
"yml",
"yaml",
"toml",
"css",
"html",
"htm",
"svg",
"png",
"jpg",
"jpeg",
"gif",
"pdf",
"rs",
"go",
"py",
"java",
"kt",
"swift",
"sh",
"bash",
"zsh",
"sql",
"lock",
])
return allowedExts.has(ext)
}
const stripTrailingPunctuation = (value: string) => {
const match = value.match(/^(.*?)([)\]}.,!?:;]+)?$/)
if (!match) return { core: value, trailing: "" }
return { core: match[1] ?? value, trailing: match[2] ?? "" }
}
let result = prompt.replace(/(^|[\s([{"'`])@([^\s@]+)/g, (full, prefix, rawToken) => {
const { core, trailing } = stripTrailingPunctuation(String(rawToken))
if (!core) return full
// If this path has a file attachment, keep the `@` (attachment is sent separately).
if (fileAttachmentPaths.has(core) || fileAttachmentPaths.has(core.replace(/\/$/, ""))) {
return `${prefix}@${core}${trailing}`
}
// Only strip for path-like tokens; leave plain `@mentions` intact.
if (!isPathLike(core)) {
return `${prefix}@${core}${trailing}`
}
return `${prefix}${core}${trailing}`
})
// Then, resolve [pasted #N] placeholders
if (!result.includes("[pasted #")) {
return result
}
if (!attachments || attachments.length === 0) {
return prompt
return result
}
const lookup = new Map<string, string>()
@@ -26,10 +114,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
}
if (lookup.size === 0) {
return prompt
return result
}
return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
return result.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
const replacement = lookup.get(fullMatch)
return typeof replacement === "string" ? replacement : fullMatch
})

View File

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

View File

@@ -46,11 +46,6 @@
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,19 +153,6 @@
@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;