Compare commits
7 Commits
codenomad/
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f1dd4150 | ||
|
|
b7f638f07d | ||
|
|
32113ea100 | ||
|
|
b31135f622 | ||
|
|
f58267dd30 | ||
|
|
95c747923c | ||
|
|
1ef01da019 |
19
README.md
19
README.md
@@ -44,22 +44,19 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
For dev version
|
||||||
- [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
|
```bash
|
||||||
npx @neuralnomads/codenomad-dev --launch
|
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
|
## Highlights
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ You can run CodeNomad directly without installing it:
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
To list all CLI options:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npx @neuralnomads/codenomad --help
|
|
||||||
```
|
|
||||||
|
|
||||||
On startup, CodeNomad prints two URLs:
|
On startup, CodeNomad prints two URLs:
|
||||||
|
|
||||||
- `Local Connection URL : ...` (used by desktop shells)
|
- `Local Connection URL : ...` (used by desktop shells)
|
||||||
@@ -50,16 +44,6 @@ npm install -g @neuralnomads/codenomad
|
|||||||
codenomad --launch
|
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
|
### Common Flags
|
||||||
You can configure the server using flags or environment variables:
|
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 |
|
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
| `--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-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`) |
|
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
| `--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 |
|
| `--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) |
|
| `--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
|
### HTTP vs HTTPS
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
@@ -81,13 +80,6 @@ const App: Component = () => {
|
|||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
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 = () => {
|
const updateInstanceTabBarHeight = () => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||||
@@ -301,7 +293,6 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
@@ -460,17 +451,25 @@ const App: Component = () => {
|
|||||||
<Show when={showFolderSelection()}>
|
<Show when={showFolderSelection()}>
|
||||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||||
<div class="w-full h-full relative">
|
<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
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||||
onClose={() => {
|
|
||||||
setShowFolderSelection(false)
|
|
||||||
setIsAdvancedSettingsOpen(false)
|
|
||||||
clearLaunchError()
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,10 +112,6 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const groupedCommandList = () => processedCommands().groups
|
const groupedCommandList = () => processedCommands().groups
|
||||||
const orderedCommands = () => processedCommands().ordered
|
const orderedCommands = () => processedCommands().ordered
|
||||||
|
|
||||||
const isCommandDisabled = (command: Command) => {
|
|
||||||
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
|
|
||||||
}
|
|
||||||
const selectedIndex = createMemo(() => {
|
const selectedIndex = createMemo(() => {
|
||||||
const ordered = orderedCommands()
|
const ordered = orderedCommands()
|
||||||
if (ordered.length === 0) return -1
|
if (ordered.length === 0) return -1
|
||||||
@@ -145,8 +141,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const currentId = selectedCommandId()
|
const currentId = selectedCommandId()
|
||||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||||
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
|
setSelectedCommandId(ordered[0].id)
|
||||||
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -200,14 +195,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
if (index < 0 || index >= ordered.length) return
|
if (index < 0 || index >= ordered.length) return
|
||||||
const command = ordered[index]
|
const command = ordered[index]
|
||||||
if (!command) return
|
if (!command) return
|
||||||
if (isCommandDisabled(command)) return
|
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommandClick(command: Command) {
|
function handleCommandClick(command: Command) {
|
||||||
if (isCommandDisabled(command)) return
|
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
@@ -272,13 +265,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
<For each={group.commands}>
|
<For each={group.commands}>
|
||||||
{(command, localIndex) => {
|
{(command, localIndex) => {
|
||||||
const commandIndex = group.startIndex + localIndex()
|
const commandIndex = group.startIndex + localIndex()
|
||||||
const disabled = isCommandDisabled(command)
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-command-index={commandIndex}
|
data-command-index={commandIndex}
|
||||||
onClick={() => handleCommandClick(command)}
|
onClick={() => handleCommandClick(command)}
|
||||||
disabled={disabled}
|
|
||||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||||
onPointerMove={(event) => {
|
onPointerMove={(event) => {
|
||||||
if (event.movementX === 0 && event.movementY === 0) return
|
if (event.movementX === 0 && event.movementY === 0) return
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-footer keyboard-hints">
|
<div class="panel-footer">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Select } from "@kobalte/core/select"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
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 { useConfig } from "../stores/preferences"
|
||||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
@@ -23,7 +23,6 @@ interface FolderSelectionViewProps {
|
|||||||
onAdvancedSettingsOpen?: () => void
|
onAdvancedSettingsOpen?: () => void
|
||||||
onAdvancedSettingsClose?: () => void
|
onAdvancedSettingsClose?: () => void
|
||||||
onOpenRemoteAccess?: () => void
|
onOpenRemoteAccess?: () => void
|
||||||
onClose?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
@@ -377,17 +376,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<MonitorUp class="w-4 h-4" />
|
<MonitorUp class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</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" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6 text-center shrink-0">
|
<div class="mb-6 text-center shrink-0">
|
||||||
<div class="mb-3 flex justify-center">
|
<div class="mb-3 flex justify-center">
|
||||||
@@ -560,7 +548,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
: t("folderSelection.browse.button")}
|
: t("folderSelection.browse.button")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -585,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
</div>
|
</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">
|
<div class="panel-footer-hints">
|
||||||
<Show when={folders().length > 0}>
|
<Show when={folders().length > 0}>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
@@ -603,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1.5">
|
<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>
|
<span>{t("folderSelection.hints.browse")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface HintRowProps {
|
|||||||
|
|
||||||
const HintRow: Component<HintRowProps> = (props) => {
|
const HintRow: Component<HintRowProps> = (props) => {
|
||||||
return (
|
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}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
<span>{t("instanceWelcome.new.createButton")}</span>
|
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
|
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="panel-footer hidden sm:block keyboard-hints">
|
<div class="panel-footer hidden sm:block">
|
||||||
|
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -633,7 +633,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
>
|
>
|
||||||
{t("instanceShell.commandPalette.button")}
|
{t("instanceShell.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint kbd-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -730,7 +730,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
<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" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Show, createSignal } from "solid-js"
|
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 type { MessageInfo, ClientPart } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
@@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard"
|
|||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessagePart } from "../stores/session-actions"
|
import { deleteMessagePart } from "../stores/session-actions"
|
||||||
|
import { isTauriHost } from "../lib/runtime-env"
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
record: MessageRecord
|
record: MessageRecord
|
||||||
@@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
const messageParts = () => props.parts
|
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 = () =>
|
const fileAttachments = () =>
|
||||||
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
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://")) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +383,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
messageType={props.record.role}
|
messageType={props.record.role}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
|
primaryUserTextPartId={primaryUserTextPartId()}
|
||||||
onRendered={props.onContentRendered}
|
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" />
|
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate max-w-[180px]">{name}</span>
|
<span class="truncate max-w-[180px]">{name}</span>
|
||||||
|
<Show when={!attachment.url?.startsWith("file://")}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleAttachmentDownload(attachment)}
|
onClick={() => void handleAttachmentDownload(attachment)}
|
||||||
class="attachment-download"
|
class="attachment-download"
|
||||||
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
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">
|
<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="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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
{t("messageListHeader.commandPalette.button")}
|
{t("messageListHeader.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ interface MessagePartProps {
|
|||||||
messageType?: "user" | "assistant"
|
messageType?: "user" | "assistant"
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: 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
|
onRendered?: () => void
|
||||||
}
|
}
|
||||||
export default function MessagePart(props: MessagePartProps) {
|
export default function MessagePart(props: MessagePartProps) {
|
||||||
@@ -28,8 +31,19 @@ interface MessagePartProps {
|
|||||||
const shouldHideTextPart = () => {
|
const shouldHideTextPart = () => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
if (!part || part.type !== "text") return false
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -867,7 +867,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
<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>
|
||||||
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={shouldShowOverlay()}>
|
<Show when={shouldShowOverlay()}>
|
||||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
<Show
|
<Show
|
||||||
when={props.escapeInDebounce}
|
when={props.escapeInDebounce}
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||||||
|
|
||||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||||
const currentAttachments = options.getAttachments()
|
const currentAttachments = options.getAttachments()
|
||||||
const attachment = currentAttachments.find(
|
const attachment = currentAttachments.find((a) => {
|
||||||
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
|
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) {
|
if (attachment) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||||||
textarea.setSelectionRange(mentionStart, mentionStart)
|
textarea.setSelectionRange(mentionStart, mentionStart)
|
||||||
}, 0)
|
}, 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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createSignal, type Accessor, type Setter } from "solid-js"
|
import { createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||||
import type { Agent } from "../../types/session"
|
import type { Agent } from "../../types/session"
|
||||||
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||||
import { addAttachment, getAttachments } from "../../stores/attachments"
|
import { addAttachment, getAttachments } from "../../stores/attachments"
|
||||||
import type { PickerMode } from "./types"
|
import type { PickerMode } from "./types"
|
||||||
|
import type { PickerSelectAction } from "../unified-picker"
|
||||||
|
|
||||||
type PickerItem =
|
type PickerItem =
|
||||||
| { type: "agent"; agent: Agent }
|
| { type: "agent"; agent: Agent }
|
||||||
@@ -37,7 +38,7 @@ type PromptPickerController = {
|
|||||||
setIgnoredAtPositions: Setter<Set<number>>
|
setIgnoredAtPositions: Setter<Set<number>>
|
||||||
|
|
||||||
handleInput: (e: Event) => void
|
handleInput: (e: Event) => void
|
||||||
handlePickerSelect: (item: PickerItem) => void
|
handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
handlePickerClose: () => void
|
handlePickerClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePickerSelect(item: PickerItem) {
|
function handlePickerSelect(item: PickerItem, action: PickerSelectAction) {
|
||||||
const textarea = options.getTextarea()
|
const textarea = options.getTextarea()
|
||||||
|
|
||||||
if (item.type === "command") {
|
if (item.type === "command") {
|
||||||
|
// For commands, Tab/Enter/Shift+Enter/click all mean "select".
|
||||||
const name = item.command.name
|
const name = item.command.name
|
||||||
const currentPrompt = options.prompt()
|
const currentPrompt = options.prompt()
|
||||||
|
|
||||||
@@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
} else if (item.type === "agent") {
|
} else if (item.type === "agent") {
|
||||||
|
// For agents, Tab/Enter/Shift+Enter/click all mean "select".
|
||||||
const agentName = item.agent.name
|
const agentName = item.agent.name
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
const alreadyAttached = existingAttachments.some(
|
const alreadyAttached = existingAttachments.some(
|
||||||
@@ -163,35 +166,127 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const relativePath = item.file.relativePath ?? displayPath
|
const relativePath = item.file.relativePath ?? displayPath
|
||||||
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
||||||
|
|
||||||
if (isFolder) {
|
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
const cursorPos = textarea?.selectionStart || 0
|
const cursorPos = textarea?.selectionStart || 0
|
||||||
const folderMention =
|
|
||||||
relativePath === "." || relativePath === ""
|
|
||||||
? "/"
|
|
||||||
: relativePath.replace(/\/+$/, "") + "/"
|
|
||||||
|
|
||||||
if (pos !== null) {
|
const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => {
|
||||||
const before = currentPrompt.substring(0, pos + 1)
|
if (pos === null) return
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const before = currentPrompt.substring(0, pos)
|
||||||
const after = currentPrompt.substring(cursorPos)
|
const after = currentPrompt.substring(cursorPos)
|
||||||
const newPrompt = before + folderMention + after
|
const suffix = opts?.trailingSpace ? " " : ""
|
||||||
options.setPrompt(newPrompt)
|
const nextPrompt = before + mentionText + suffix + after
|
||||||
setSearchQuery(folderMention)
|
options.setPrompt(nextPrompt)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const nextTextarea = options.getTextarea()
|
const nextTextarea = options.getTextarea()
|
||||||
if (nextTextarea) {
|
if (!nextTextarea) return
|
||||||
const newCursorPos = pos + 1 + folderMention.length
|
const nextCursorPos = pos + mentionText.length + suffix.length
|
||||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
|
||||||
}
|
|
||||||
}, 0)
|
}, 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 === "./"
|
||||||
|
? "./"
|
||||||
|
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
|
||||||
|
|
||||||
|
const normalizedFolderPath = (() => {
|
||||||
|
const trimmed = relativePath.replace(/\/+$/, "")
|
||||||
|
// If it's root "./", just return "./"
|
||||||
|
if (trimmed === "" || trimmed === ".") return "./"
|
||||||
|
// Otherwise remove any leading ./ and add ./ prefix
|
||||||
|
return "./" + trimmed.replace(/^\.\//, "")
|
||||||
|
})()
|
||||||
|
|
||||||
|
const addPathOnlyAttachment = (value: string) => {
|
||||||
|
const display = `path: ${value}`
|
||||||
|
const filename = value
|
||||||
|
const existing = getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const alreadyAttached = existing.some(
|
||||||
|
(att) => att.source.type === "text" && att.source.value === value && att.display === display,
|
||||||
|
)
|
||||||
|
if (!alreadyAttached) {
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
if (action === "tab") {
|
||||||
|
// TAB on directory: autocomplete directory name and show its contents.
|
||||||
|
replaceMentionQueryAfterAt(folderMention)
|
||||||
|
setSearchQuery(folderMention)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mentionText = `@${folderMention}`
|
||||||
|
|
||||||
|
if (action === "shiftEnter") {
|
||||||
|
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
|
||||||
|
// Always prefix with ./ for consistency
|
||||||
|
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
|
||||||
|
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
|
||||||
|
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) {
|
||||||
|
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
|
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, add text attachment, remove @ when sending
|
||||||
|
// Always prefix with ./ for consistency
|
||||||
|
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||||
|
addPathOnlyAttachment(normalizedPathWithPrefix)
|
||||||
|
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
||||||
|
} else {
|
||||||
|
// ENTER/click on file: attach file (existing behavior).
|
||||||
|
// Always prefix with ./ for consistency
|
||||||
|
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||||
const pathSegments = normalizedPath.split("/")
|
const pathSegments = normalizedPath.split("/")
|
||||||
const filename = (() => {
|
const filename = (() => {
|
||||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||||
@@ -200,12 +295,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
|
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
const alreadyAttached = existingAttachments.some(
|
const alreadyAttached = existingAttachments.some(
|
||||||
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!alreadyAttached) {
|
if (!alreadyAttached) {
|
||||||
const attachment = createFileAttachment(
|
const attachment = createFileAttachment(
|
||||||
normalizedPath,
|
normalizedPathWithPrefix,
|
||||||
filename,
|
filename,
|
||||||
"text/plain",
|
"text/plain",
|
||||||
undefined,
|
undefined,
|
||||||
@@ -214,24 +309,8 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPrompt = options.prompt()
|
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
||||||
const pos = atPosition()
|
|
||||||
const cursorPos = textarea?.selectionStart || 0
|
|
||||||
|
|
||||||
if (pos !== null) {
|
|
||||||
const before = currentPrompt.substring(0, pos)
|
|
||||||
const after = currentPrompt.substring(cursorPos)
|
|
||||||
const attachmentText = `@${normalizedPath}`
|
|
||||||
const newPrompt = before + attachmentText + " " + after
|
|
||||||
options.setPrompt(newPrompt)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const nextTextarea = options.getTextarea()
|
|
||||||
if (nextTextarea) {
|
|
||||||
const newCursorPos = pos + attachmentText.length + 1
|
|
||||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
|
||||||
}
|
}
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +324,28 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
if (pickerMode() === "mention" && pos !== null) {
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
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)
|
||||||
|
|
||||||
|
// Clear ignoredAtPositions so typing @ again will work
|
||||||
|
setIgnoredAtPositions(new Set<number>())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<kbd class="kbd ml-2 kbd-hint">
|
<kbd class="kbd ml-2">
|
||||||
Cmd+Enter
|
Cmd+Enter
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,9 +51,7 @@ function normalizeQuery(rawQuery: string) {
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if (trimmed === "." || trimmed === "./") {
|
// Don't normalize "." - it's used for workspace root
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
|
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +72,12 @@ type PickerItem =
|
|||||||
| { type: "file"; file: FileItem }
|
| { type: "file"; file: FileItem }
|
||||||
| { type: "command"; command: SDKCommand }
|
| { type: "command"; command: SDKCommand }
|
||||||
|
|
||||||
|
export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter"
|
||||||
|
|
||||||
interface UnifiedPickerProps {
|
interface UnifiedPickerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
mode?: "mention" | "command"
|
mode?: "mention" | "command"
|
||||||
onSelect: (item: PickerItem) => void
|
onSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
agents: Agent[]
|
agents: Agent[]
|
||||||
commands?: SDKCommand[]
|
commands?: SDKCommand[]
|
||||||
@@ -266,6 +266,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
const workspaceChanged = lastWorkspaceId !== props.workspaceId
|
const workspaceChanged = lastWorkspaceId !== props.workspaceId
|
||||||
const queryChanged = lastQuery !== props.searchQuery
|
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) {
|
if (!isInitialized() || workspaceChanged || queryChanged) {
|
||||||
setIsInitialized(true)
|
setIsInitialized(true)
|
||||||
lastWorkspaceId = props.workspaceId
|
lastWorkspaceId = props.workspaceId
|
||||||
@@ -341,7 +348,22 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/")
|
||||||
|
const isExactRootQuery = props.searchQuery === "." || props.searchQuery === "./"
|
||||||
|
if (mode() === "mention" && isExactRootQuery) {
|
||||||
|
const rootFile: FileItem = {
|
||||||
|
path: ".",
|
||||||
|
relativePath: ".",
|
||||||
|
isDirectory: true,
|
||||||
|
isGitFile: false,
|
||||||
|
}
|
||||||
|
items.push({ type: "file", file: rootFile })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show agents for exact root path queries
|
||||||
|
if (!isExactRootQuery) {
|
||||||
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
||||||
|
}
|
||||||
files().forEach((file) => items.push({ type: "file", file }))
|
files().forEach((file) => items.push({ type: "file", file }))
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
@@ -356,7 +378,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect(item: PickerItem) {
|
function handleSelect(item: PickerItem) {
|
||||||
props.onSelect(item)
|
props.onSelect(item, "click")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
@@ -379,7 +401,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const selected = items[selectedIndex()]
|
const selected = items[selectedIndex()]
|
||||||
if (selected) {
|
if (selected) {
|
||||||
handleSelect(selected)
|
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
|
||||||
|
props.onSelect(selected, action)
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -443,7 +466,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
|
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
|
||||||
data-picker-selected={isSelected()}
|
data-picker-selected={isSelected()}
|
||||||
onClick={() => handleSelect({ type: "command", command })}
|
onClick={() => props.onSelect({ type: "command", command }, "click")}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<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">
|
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -464,7 +487,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={mode() === "mention" && agentCount() > 0}>
|
<Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}>
|
||||||
<div class="dropdown-section-header">
|
<div class="dropdown-section-header">
|
||||||
{t("unifiedPicker.sections.agents")}
|
{t("unifiedPicker.sections.agents")}
|
||||||
</div>
|
</div>
|
||||||
@@ -479,7 +502,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||||
}`}
|
}`}
|
||||||
data-picker-selected={itemIndex === selectedIndex()}
|
data-picker-selected={itemIndex === selectedIndex()}
|
||||||
onClick={() => handleSelect({ type: "agent", agent })}
|
onClick={() => props.onSelect({ type: "agent", agent }, "click")}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<svg
|
<svg
|
||||||
@@ -519,10 +542,39 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={mode() === "mention" && fileCount() > 0}>
|
<Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}>
|
||||||
<div class="dropdown-section-header">
|
<div class="dropdown-section-header">
|
||||||
{t("unifiedPicker.sections.files")}
|
{t("unifiedPicker.sections.files")}
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={props.searchQuery === "." || props.searchQuery === "./"}>
|
||||||
|
<div
|
||||||
|
class={`dropdown-item py-1.5 ${
|
||||||
|
selectedIndex() === 0 ? "dropdown-item-highlight" : ""
|
||||||
|
}`}
|
||||||
|
data-picker-selected={selectedIndex() === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const rootFile: FileItem = {
|
||||||
|
path: ".",
|
||||||
|
relativePath: ".",
|
||||||
|
isDirectory: true,
|
||||||
|
isGitFile: false,
|
||||||
|
}
|
||||||
|
props.onSelect({ type: "file", file: rootFile }, "click")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<svg class="dropdown-icon h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono">. {t("unifiedPicker.sections.workspaceRoot")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file) => {
|
{(file) => {
|
||||||
const itemIndex = allItems().findIndex(
|
const itemIndex = allItems().findIndex(
|
||||||
@@ -535,7 +587,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||||
}`}
|
}`}
|
||||||
data-picker-selected={itemIndex === selectedIndex()}
|
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">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export interface Command {
|
|||||||
description: Resolvable<string>
|
description: Resolvable<string>
|
||||||
keywords?: Resolvable<string[]>
|
keywords?: Resolvable<string[]>
|
||||||
shortcut?: KeyboardShortcut
|
shortcut?: KeyboardShortcut
|
||||||
disabled?: Resolvable<boolean>
|
|
||||||
action: () => void | Promise<void>
|
action: () => void | Promise<void>
|
||||||
category?: Resolvable<string>
|
category?: Resolvable<string>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { getLogger } from "../logger"
|
|||||||
import { requestData } from "../opencode-api"
|
import { requestData } from "../opencode-api"
|
||||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||||
import { tGlobal } from "../i18n"
|
import { tGlobal } from "../i18n"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -29,7 +28,6 @@ function splitKeywords(key: string): string[] {
|
|||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
preferences: Accessor<Preferences>
|
preferences: Accessor<Preferences>
|
||||||
toggleShowThinkingBlocks: () => void
|
toggleShowThinkingBlocks: () => void
|
||||||
toggleKeyboardShortcutHints: () => void
|
|
||||||
toggleShowTimelineTools: () => void
|
toggleShowTimelineTools: () => void
|
||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
toggleAutoCleanupBlankSessions: () => void
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
@@ -456,26 +454,6 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
action: options.toggleShowTimelineTools,
|
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({
|
commandRegistry.register({
|
||||||
id: "thinking-default-visibility",
|
id: "thinking-default-visibility",
|
||||||
label: () => {
|
label: () => {
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
|
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
|
||||||
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
|
"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.expanded": "Expanded",
|
||||||
"commands.common.collapsed": "Collapsed",
|
"commands.common.collapsed": "Collapsed",
|
||||||
"commands.common.visible": "Visible",
|
"commands.common.visible": "Visible",
|
||||||
@@ -164,6 +158,7 @@ export const commandMessages = {
|
|||||||
"unifiedPicker.sections.commands": "COMMANDS",
|
"unifiedPicker.sections.commands": "COMMANDS",
|
||||||
"unifiedPicker.sections.agents": "AGENTS",
|
"unifiedPicker.sections.agents": "AGENTS",
|
||||||
"unifiedPicker.sections.files": "FILES",
|
"unifiedPicker.sections.files": "FILES",
|
||||||
|
"unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT",
|
||||||
"unifiedPicker.badge.subagent": "subagent",
|
"unifiedPicker.badge.subagent": "subagent",
|
||||||
"unifiedPicker.footer.navigate": "navigate",
|
"unifiedPicker.footer.navigate": "navigate",
|
||||||
"unifiedPicker.footer.select": "select",
|
"unifiedPicker.footer.select": "select",
|
||||||
|
|||||||
@@ -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.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.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.expanded": "Expandido",
|
||||||
"commands.common.collapsed": "Colapsado",
|
"commands.common.collapsed": "Colapsado",
|
||||||
"commands.common.visible": "Visible",
|
"commands.common.visible": "Visible",
|
||||||
|
|||||||
@@ -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.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages",
|
||||||
"commands.timelineToolCalls.keywords": "timeline, outil, basculer",
|
"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.expanded": "Développé",
|
||||||
"commands.common.collapsed": "Réduit",
|
"commands.common.collapsed": "Réduit",
|
||||||
"commands.common.visible": "Visible",
|
"commands.common.visible": "Visible",
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
|
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
|
||||||
"commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle",
|
"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.expanded": "展開",
|
||||||
"commands.common.collapsed": "折りたたみ",
|
"commands.common.collapsed": "折りたたみ",
|
||||||
"commands.common.visible": "表示",
|
"commands.common.visible": "表示",
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
|
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
|
||||||
"commands.timelineToolCalls.keywords": "таймлайн, tool, переключить",
|
"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.expanded": "Развернуто",
|
||||||
"commands.common.collapsed": "Свернуто",
|
"commands.common.collapsed": "Свернуто",
|
||||||
"commands.common.visible": "Видимо",
|
"commands.common.visible": "Видимо",
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
|
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
|
||||||
"commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换",
|
"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.expanded": "展开",
|
||||||
"commands.common.collapsed": "折叠",
|
"commands.common.collapsed": "折叠",
|
||||||
"commands.common.visible": "可见",
|
"commands.common.visible": "可见",
|
||||||
|
|||||||
@@ -1,12 +1,59 @@
|
|||||||
import type { Attachment } from "../types/attachment"
|
import type { Attachment, FileSource } from "../types/attachment"
|
||||||
|
|
||||||
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
|
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
|
||||||
if (!prompt || !prompt.includes("[pasted #")) {
|
if (!prompt) {
|
||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileAttachments = new Set(
|
||||||
|
attachments
|
||||||
|
.filter((a): a is Attachment & { source: FileSource } => a.source.type === "file")
|
||||||
|
.map((a) => a.source.path),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pathAttachments = new Set(
|
||||||
|
attachments
|
||||||
|
.filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:"))
|
||||||
|
.map((a) => (a.source as { value: string }).value),
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = prompt
|
||||||
|
|
||||||
|
// Step 1: Handle root paths FIRST using unique placeholders
|
||||||
|
// Replace longer pattern first to avoid partial match issues
|
||||||
|
result = result.replace(/@(\.\/)/g, "___ROOT___")
|
||||||
|
result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___")
|
||||||
|
// Note: The regex @(\.)(?!\.) means @. NOT followed by another .
|
||||||
|
|
||||||
|
// Step 2: Build set of non-root paths
|
||||||
|
const allPaths = new Set<string>()
|
||||||
|
for (const p of fileAttachments) {
|
||||||
|
if (p && p !== "." && p !== "./") allPaths.add(p)
|
||||||
|
}
|
||||||
|
for (const p of pathAttachments) {
|
||||||
|
if (p && p !== "." && p !== "./") allPaths.add(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Replace @path with ./path for non-root paths
|
||||||
|
for (const path of allPaths) {
|
||||||
|
if (!path) continue
|
||||||
|
const withoutPrefix = path.startsWith("./") ? path.slice(2) : path
|
||||||
|
const withPrefix = path.startsWith("./") ? path : "./" + path
|
||||||
|
result = result.replace("@" + withoutPrefix, withPrefix)
|
||||||
|
result = result.replace("@" + withoutPrefix + "/", withPrefix + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Convert placeholders back to ./
|
||||||
|
result = result.replace("___ROOT___", "./")
|
||||||
|
result = result.replace("___ROOT_NOSLASH___", "./")
|
||||||
|
|
||||||
|
// Step 5: Resolve [pasted #N] placeholders
|
||||||
|
if (!result.includes("[pasted #")) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
if (!attachments || attachments.length === 0) {
|
if (!attachments || attachments.length === 0) {
|
||||||
return prompt
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookup = new Map<string, string>()
|
const lookup = new Map<string, string>()
|
||||||
@@ -15,7 +62,7 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
|
|||||||
const source = attachment?.source
|
const source = attachment?.source
|
||||||
if (!source || source.type !== "text") continue
|
if (!source || source.type !== "text") continue
|
||||||
const display = attachment?.display
|
const display = attachment?.display
|
||||||
const value = source.value
|
const value = (source as { value?: string }).value
|
||||||
if (typeof display !== "string" || typeof value !== "string") continue
|
if (typeof display !== "string" || typeof value !== "string") continue
|
||||||
const match = display.match(/pasted #(\d+)/)
|
const match = display.match(/pasted #(\d+)/)
|
||||||
if (!match) continue
|
if (!match) continue
|
||||||
@@ -26,10 +73,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lookup.size === 0) {
|
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)
|
const replacement = lookup.get(fullMatch)
|
||||||
return typeof replacement === "string" ? replacement : fullMatch
|
return typeof replacement === "string" ? replacement : fullMatch
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export type ListeningMode = "local" | "all"
|
|||||||
|
|
||||||
export interface Preferences {
|
export interface Preferences {
|
||||||
showThinkingBlocks: boolean
|
showThinkingBlocks: boolean
|
||||||
showKeyboardShortcutHints: boolean
|
|
||||||
thinkingBlocksExpansion: ExpansionPreference
|
thinkingBlocksExpansion: ExpansionPreference
|
||||||
showTimelineTools: boolean
|
showTimelineTools: boolean
|
||||||
promptSubmitOnEnter: boolean
|
promptSubmitOnEnter: boolean
|
||||||
@@ -79,7 +78,6 @@ const MAX_FAVORITE_MODELS = 50
|
|||||||
|
|
||||||
const defaultPreferences: Preferences = {
|
const defaultPreferences: Preferences = {
|
||||||
showThinkingBlocks: false,
|
showThinkingBlocks: false,
|
||||||
showKeyboardShortcutHints: true,
|
|
||||||
thinkingBlocksExpansion: "expanded",
|
thinkingBlocksExpansion: "expanded",
|
||||||
showTimelineTools: true,
|
showTimelineTools: true,
|
||||||
promptSubmitOnEnter: false,
|
promptSubmitOnEnter: false,
|
||||||
@@ -133,7 +131,6 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||||
showKeyboardShortcutHints: sanitized.showKeyboardShortcutHints ?? defaultPreferences.showKeyboardShortcutHints,
|
|
||||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||||
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
|
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
|
||||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter,
|
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter,
|
||||||
@@ -396,10 +393,6 @@ function toggleShowThinkingBlocks(): void {
|
|||||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleKeyboardShortcutHints(): void {
|
|
||||||
updatePreferences({ showKeyboardShortcutHints: !preferences().showKeyboardShortcutHints })
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleShowTimelineTools(): void {
|
function toggleShowTimelineTools(): void {
|
||||||
updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
|
updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
|
||||||
}
|
}
|
||||||
@@ -518,7 +511,6 @@ interface ConfigContextValue {
|
|||||||
setThemePreference: typeof setThemePreference
|
setThemePreference: typeof setThemePreference
|
||||||
updateConfig: typeof updateConfig
|
updateConfig: typeof updateConfig
|
||||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||||
toggleKeyboardShortcutHints: typeof toggleKeyboardShortcutHints
|
|
||||||
toggleShowTimelineTools: typeof toggleShowTimelineTools
|
toggleShowTimelineTools: typeof toggleShowTimelineTools
|
||||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||||
@@ -556,7 +548,6 @@ const configContextValue: ConfigContextValue = {
|
|||||||
setThemePreference,
|
setThemePreference,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
@@ -617,7 +608,6 @@ export {
|
|||||||
updateConfig,
|
updateConfig,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
|
|||||||
@@ -140,8 +140,11 @@ async function sendMessage(
|
|||||||
const display: string | undefined = att.display
|
const display: string | undefined = att.display
|
||||||
const value: unknown = source.value
|
const value: unknown = source.value
|
||||||
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
||||||
|
const isPathPlaceholder = typeof display === "string" && /^path:/.test(display)
|
||||||
|
|
||||||
if (isPastedPlaceholder || typeof value !== "string") {
|
// Skip path: attachments from being sent as separate parts (content is already in prompt)
|
||||||
|
// Skip pasted placeholders too (already resolved in prompt)
|
||||||
|
if (isPastedPlaceholder || isPathPlaceholder || typeof value !== "string") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,11 +46,6 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-item:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
||||||
background-color: var(--surface-hover);
|
background-color: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,19 +153,6 @@
|
|||||||
@apply opacity-50;
|
@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 from the start (keeps end visible; good for paths) */
|
||||||
.truncate-start {
|
.truncate-start {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user