Compare commits
11 Commits
v0.10.3-de
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c24a7daf3 | ||
|
|
682937e945 | ||
|
|
35ff359c0f | ||
|
|
c7195469bd | ||
|
|
e9f281a69d | ||
|
|
36baac06b8 | ||
|
|
3678214e69 | ||
|
|
338e3d9d38 | ||
|
|
0c0f397db0 | ||
|
|
da70cc9944 | ||
|
|
ba418a8518 |
3
.github/workflows/dev-release.yml
vendored
3
.github/workflows/dev-release.yml
vendored
@@ -34,7 +34,8 @@ jobs:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
||||
dist_tag: dev
|
||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||
dist_tag: latest
|
||||
prerelease: true
|
||||
release_ui: false
|
||||
secrets: inherit
|
||||
|
||||
20
.github/workflows/manual-npm-publish.yml
vendored
20
.github/workflows/manual-npm-publish.yml
vendored
@@ -12,6 +12,11 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
package_name:
|
||||
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
|
||||
required: false
|
||||
default: "@neuralnomads/codenomad"
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
@@ -21,6 +26,10 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: dev
|
||||
package_name:
|
||||
required: false
|
||||
type: string
|
||||
default: "@neuralnomads/codenomad"
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
@@ -54,7 +63,7 @@ jobs:
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package (includes UI bundling)
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
run: npm run build --workspace packages/server
|
||||
|
||||
- name: Set publish metadata
|
||||
shell: bash
|
||||
@@ -65,10 +74,17 @@ jobs:
|
||||
fi
|
||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump package version for publish
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Set server package name for publish
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
|
||||
|
||||
- name: Publish server package with provenance
|
||||
env:
|
||||
# Optional: when present, npm will use token auth.
|
||||
@@ -85,4 +101,4 @@ jobs:
|
||||
else
|
||||
echo "Using NPM_TOKEN authentication"
|
||||
fi
|
||||
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
||||
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -14,4 +14,5 @@ jobs:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
dist_tag: latest
|
||||
npm_package_name: "@neuralnomads/codenomad"
|
||||
secrets: inherit
|
||||
|
||||
6
.github/workflows/reusable-release.yml
vendored
6
.github/workflows/reusable-release.yml
vendored
@@ -13,6 +13,11 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
npm_package_name:
|
||||
description: "npm package name to publish (defaults to server package name)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Create GitHub prerelease"
|
||||
required: false
|
||||
@@ -100,4 +105,5 @@ jobs:
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: ${{ inputs.dist_tag }}
|
||||
package_name: ${{ inputs.npm_package_name }}
|
||||
secrets: inherit
|
||||
|
||||
17
README.md
17
README.md
@@ -44,18 +44,21 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
For dev version
|
||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||
- [packages/server/README.md](packages/server/README.md)
|
||||
|
||||
To see all available options:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad@dev --launch
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
|
||||
Dev builds are published as GitHub pre-releases:
|
||||
https://github.com/shantur/CodeNomad/releases
|
||||
### 🧪 Dev Releases
|
||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||
|
||||
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.
|
||||
```bash
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ You can run CodeNomad directly without installing it:
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
To list all CLI options:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
|
||||
On startup, CodeNomad prints two URLs:
|
||||
|
||||
- `Local Connection URL : ...` (used by desktop shells)
|
||||
@@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad
|
||||
codenomad --launch
|
||||
```
|
||||
|
||||
### Install Locally (per-project)
|
||||
If you prefer to install CodeNomad into a project and run the local binary:
|
||||
|
||||
```sh
|
||||
npm install @neuralnomads/codenomad
|
||||
npx codenomad --launch
|
||||
```
|
||||
|
||||
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
||||
|
||||
### Common Flags
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
@@ -63,10 +79,30 @@ You can configure the server using flags or environment variables:
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
|
||||
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
||||
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
||||
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
|
||||
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
||||
|
||||
### Dev Releases (Advanced)
|
||||
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
These environment variables control how CodeNomad checks for dev updates:
|
||||
|
||||
| Env Variable | Description |
|
||||
|-------------|-------------|
|
||||
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
|
||||
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
|
||||
|
||||
### HTTP vs HTTPS
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ const App: Component = () => {
|
||||
preferences,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
@@ -80,6 +81,13 @@ const App: Component = () => {
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const shouldShow =
|
||||
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||
})
|
||||
|
||||
const updateInstanceTabBarHeight = () => {
|
||||
if (typeof document === "undefined") return
|
||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||
@@ -293,6 +301,7 @@ const App: Component = () => {
|
||||
preferences,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
|
||||
@@ -112,6 +112,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
|
||||
const groupedCommandList = () => processedCommands().groups
|
||||
const orderedCommands = () => processedCommands().ordered
|
||||
|
||||
const isCommandDisabled = (command: Command) => {
|
||||
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
|
||||
}
|
||||
const selectedIndex = createMemo(() => {
|
||||
const ordered = orderedCommands()
|
||||
if (ordered.length === 0) return -1
|
||||
@@ -138,10 +142,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const currentId = selectedCommandId()
|
||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||
setSelectedCommandId(ordered[0].id)
|
||||
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
|
||||
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -195,12 +200,14 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
if (index < 0 || index >= ordered.length) return
|
||||
const command = ordered[index]
|
||||
if (!command) return
|
||||
if (isCommandDisabled(command)) return
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandClick(command: Command) {
|
||||
if (isCommandDisabled(command)) return
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
@@ -265,11 +272,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
<For each={group.commands}>
|
||||
{(command, localIndex) => {
|
||||
const commandIndex = group.startIndex + localIndex()
|
||||
const disabled = isCommandDisabled(command)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-command-index={commandIndex}
|
||||
onClick={() => handleCommandClick(command)}
|
||||
disabled={disabled}
|
||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||
onPointerMove={(event) => {
|
||||
if (event.movementX === 0 && event.movementY === 0) return
|
||||
|
||||
@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<div class="panel-footer keyboard-hints">
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
|
||||
@@ -548,7 +548,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -573,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -591,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
||||
<span>{t("folderSelection.hints.browse")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,15 @@ import { Component, JSX } from "solid-js"
|
||||
interface HintRowProps {
|
||||
children: JSX.Element
|
||||
class?: string
|
||||
ariaHidden?: boolean
|
||||
}
|
||||
|
||||
const HintRow: Component<HintRowProps> = (props) => {
|
||||
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
|
||||
return (
|
||||
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default HintRow
|
||||
|
||||
@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
)}
|
||||
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||
</div>
|
||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel-footer hidden sm:block">
|
||||
<div class="panel-footer hidden sm:block keyboard-hints">
|
||||
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
|
||||
@@ -633,7 +633,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
>
|
||||
{t("instanceShell.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<span class="connection-status-shortcut-hint kbd-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
@@ -730,7 +730,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<span class="connection-status-shortcut-hint kbd-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Show, type Accessor, type Component } from "solid-js"
|
||||
import type { SessionThread } from "../../../stores/session-state"
|
||||
import type { Session } from "../../../types/session"
|
||||
import type { KeyboardShortcut } from "../../../lib/keyboard-registry"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry"
|
||||
import type { DrawerViewState } from "./types"
|
||||
|
||||
import { PlusSquare, Search } from "lucide-solid"
|
||||
@@ -13,7 +13,6 @@ import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
|
||||
|
||||
import SessionList from "../../session-list"
|
||||
import KeyboardHint from "../../keyboard-hint"
|
||||
import Kbd from "../../kbd"
|
||||
import WorktreeSelector from "../../worktree-selector"
|
||||
import AgentSelector from "../../agent-selector"
|
||||
import ModelSelector from "../../model-selector"
|
||||
@@ -166,11 +165,17 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
|
||||
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
|
||||
|
||||
<div class="session-sidebar-selector-hints" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
<Kbd shortcut="cmd+shift+t" />
|
||||
</div>
|
||||
<KeyboardHint
|
||||
class="session-sidebar-selector-hints"
|
||||
ariaHidden={true}
|
||||
shortcuts={[
|
||||
keyboardRegistry.get("open-agent-selector"),
|
||||
keyboardRegistry.get("focus-model"),
|
||||
keyboardRegistry.get("focus-variant"),
|
||||
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
|
||||
separator=" "
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, JSX, For } from "solid-js"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
|
||||
interface KbdProps {
|
||||
@@ -27,6 +28,9 @@ const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
}
|
||||
|
||||
const Kbd: Component<KbdProps> = (props) => {
|
||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||
if (!desktopQuery()) return null
|
||||
|
||||
const parts = () => {
|
||||
if (props.children) return [{ text: props.children, isModifier: false }]
|
||||
if (!props.shortcut) return []
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Component, For } from "solid-js"
|
||||
import { formatShortcut, isMac } from "../lib/keyboard-utils"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import type { KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import Kbd from "./kbd"
|
||||
import HintRow from "./hint-row"
|
||||
|
||||
const KeyboardHint: Component<{
|
||||
shortcuts: KeyboardShortcut[]
|
||||
separator?: string
|
||||
separator?: string | null
|
||||
showDescription?: boolean
|
||||
class?: string
|
||||
ariaHidden?: boolean
|
||||
}> = (props) => {
|
||||
// Centralize layout gating here so call sites don't need to.
|
||||
// We only show keyboard hint UI on desktop layouts.
|
||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||
|
||||
function buildShortcutString(shortcut: KeyboardShortcut): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -26,12 +32,14 @@ const KeyboardHint: Component<{
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
if (!desktopQuery()) return null
|
||||
|
||||
return (
|
||||
<HintRow>
|
||||
<HintRow class={props.class} ariaHidden={props.ariaHidden}>
|
||||
<For each={props.shortcuts}>
|
||||
{(shortcut, i) => (
|
||||
<>
|
||||
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
|
||||
{i() > 0 && props.separator !== null && <span class="mx-1">{props.separator ?? "•"}</span>}
|
||||
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
|
||||
<Kbd shortcut={buildShortcutString(shortcut)} />
|
||||
</>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
{t("messageListHeader.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -867,7 +867,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<ul>
|
||||
<li>
|
||||
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2 kbd-hint" />
|
||||
</li>
|
||||
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
||||
<li>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js"
|
||||
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||
import UnifiedPicker from "./unified-picker"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import Kbd from "./kbd"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
@@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
handleDrop,
|
||||
syncAttachmentCounters,
|
||||
handleExpandTextAttachment,
|
||||
handleRemoveAttachment,
|
||||
} = usePromptAttachments({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
@@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
if (!attachment) return
|
||||
handleExpandTextAttachment(attachment)
|
||||
},
|
||||
removeAttachment: (attachmentId: string) => {
|
||||
handleRemoveAttachment(attachmentId)
|
||||
},
|
||||
setPromptText: (text: string, opts?: { focus?: boolean }) => {
|
||||
const textarea = textareaRef
|
||||
if (textarea) {
|
||||
@@ -166,10 +170,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
|
||||
const instanceId = props.instanceId
|
||||
const sessionId = props.sessionId
|
||||
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
|
||||
syncAttachmentCounters(prompt(), currentAttachments)
|
||||
syncAttachmentCounters(prompt())
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -238,10 +239,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||
if (!isKnownSlashCommand) {
|
||||
clearAttachments(props.instanceId, props.sessionId)
|
||||
syncAttachmentCounters("", [])
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
} else {
|
||||
syncAttachmentCounters("", currentAttachments)
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
}
|
||||
|
||||
@@ -479,7 +480,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
<Show
|
||||
when={props.escapeInDebounce}
|
||||
fallback={
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
|
||||
export function formatPastedPlaceholder(value: string | number) {
|
||||
return `[pasted #${value}]`
|
||||
}
|
||||
@@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) {
|
||||
}
|
||||
|
||||
export function createPastedPlaceholderRegex() {
|
||||
return /\[pasted #(\d+)\]/g
|
||||
return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi
|
||||
}
|
||||
|
||||
export function createImagePlaceholderRegex() {
|
||||
return /\[Image #(\d+)\]/g
|
||||
return /\[\s*Image\s*#\s*(\d+)\s*\]/gi
|
||||
}
|
||||
|
||||
export function createMentionRegex() {
|
||||
return /@(\S+)/g
|
||||
}
|
||||
|
||||
export const pastedDisplayCounterRegex = /pasted #(\d+)/
|
||||
export const imageDisplayCounterRegex = /Image #(\d+)/
|
||||
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
|
||||
export const pastedDisplayCounterRegex = /pasted #(\d+)/i
|
||||
export const imageDisplayCounterRegex = /Image #(\d+)/i
|
||||
export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i
|
||||
|
||||
export function parseCounter(value: string) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||
export function findHighestAttachmentCounters(currentPrompt: string) {
|
||||
let highestPaste = 0
|
||||
let highestImage = 0
|
||||
|
||||
@@ -40,27 +38,6 @@ export function findHighestAttachmentCounters(currentPrompt: string, sessionAtta
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of sessionAttachments) {
|
||||
if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (placeholderMatch) {
|
||||
const parsed = parseCounter(placeholderMatch[1])
|
||||
if (parsed !== null) {
|
||||
highestPaste = Math.max(highestPaste, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
|
||||
if (imageMatch) {
|
||||
const parsed = parseCounter(imageMatch[1])
|
||||
if (parsed !== null) {
|
||||
highestImage = Math.max(highestImage, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
|
||||
const parsed = parseCounter(match[1])
|
||||
if (parsed !== null) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code"
|
||||
export interface PromptInputApi {
|
||||
insertSelection(text: string, mode: PromptInsertMode): void
|
||||
expandTextAttachment(attachmentId: string): void
|
||||
removeAttachment(attachmentId: string): void
|
||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, type Accessor } from "solid-js"
|
||||
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findHighestAttachmentCounters,
|
||||
formatImagePlaceholder,
|
||||
formatPastedPlaceholder,
|
||||
imageDisplayCounterRegex,
|
||||
pastedDisplayCounterRegex,
|
||||
} from "./attachmentPlaceholders"
|
||||
|
||||
@@ -23,7 +24,7 @@ type PromptAttachments = {
|
||||
attachments: Accessor<Attachment[]>
|
||||
pasteCount: Accessor<number>
|
||||
imageCount: Accessor<number>
|
||||
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
|
||||
syncAttachmentCounters: (promptText: string) => void
|
||||
|
||||
handlePaste: (e: ClipboardEvent) => Promise<void>
|
||||
isDragging: Accessor<boolean>
|
||||
@@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
const [pasteCount, setPasteCount] = createSignal(0)
|
||||
const [imageCount, setImageCount] = createSignal(0)
|
||||
|
||||
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
|
||||
function syncAttachmentCounters(currentPrompt: string) {
|
||||
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt)
|
||||
setPasteCount(highestPaste)
|
||||
setImageCount(highestImage)
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) {
|
||||
const next = currentPrompt.replace(tokenRegex, "")
|
||||
if (next === currentPrompt) return currentPrompt
|
||||
|
||||
return next
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n[ \t]+/g, "\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
const createLooseImagePlaceholderRegex = (counter: string | number) =>
|
||||
new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||
const createLoosePastedPlaceholderRegex = (counter: string | number) =>
|
||||
new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||
|
||||
// Keep placeholder-backed attachments in sync with prompt text.
|
||||
// If the placeholder token disappears from the prompt, the attachment should disappear too.
|
||||
createEffect(() => {
|
||||
const currentPrompt = options.prompt()
|
||||
const currentAttachments = attachments()
|
||||
|
||||
const toRemove: string[] = []
|
||||
|
||||
for (const attachment of currentAttachments) {
|
||||
if (attachment.source.type === "text") {
|
||||
const match = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (!match) continue
|
||||
const counter = match[1]
|
||||
if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) {
|
||||
toRemove.push(attachment.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||
const match =
|
||||
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||
if (!match) continue
|
||||
const counter = match[1]
|
||||
if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) {
|
||||
toRemove.push(attachment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachmentId of toRemove) {
|
||||
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||
}
|
||||
})
|
||||
|
||||
function handleRemoveAttachment(attachmentId: string) {
|
||||
const currentAttachments = attachments()
|
||||
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
||||
|
||||
// Always remove from store.
|
||||
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||
|
||||
if (attachment) {
|
||||
const currentPrompt = options.prompt()
|
||||
let newPrompt = currentPrompt
|
||||
if (!attachment) return
|
||||
|
||||
if (attachment.source.type === "file") {
|
||||
if (attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
|
||||
if (imageMatch) {
|
||||
const placeholder = formatImagePlaceholder(imageMatch[1])
|
||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
} else {
|
||||
const filename = attachment.filename
|
||||
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
|
||||
const currentPrompt = options.prompt()
|
||||
let nextPrompt = currentPrompt
|
||||
|
||||
if (attachment.source.type === "file") {
|
||||
if (attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch =
|
||||
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||
if (imageMatch) {
|
||||
nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1]))
|
||||
}
|
||||
} else if (attachment.source.type === "agent") {
|
||||
const agentName = attachment.filename
|
||||
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
|
||||
} else if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (placeholderMatch) {
|
||||
const placeholder = formatPastedPlaceholder(placeholderMatch[1])
|
||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
||||
} else {
|
||||
// For file mentions we insert `@<path>`, but the chip might display `@<filename>`.
|
||||
const candidates = [attachment.source.path, attachment.filename]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i")
|
||||
nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex)
|
||||
}
|
||||
}
|
||||
} else if (attachment.source.type === "agent") {
|
||||
const agentName = attachment.filename
|
||||
const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i")
|
||||
nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex)
|
||||
} else if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (placeholderMatch) {
|
||||
nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1]))
|
||||
}
|
||||
}
|
||||
|
||||
options.setPrompt(newPrompt)
|
||||
if (nextPrompt !== currentPrompt) {
|
||||
options.setPrompt(nextPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,13 +205,32 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) continue
|
||||
|
||||
const count = imageCount() + 1
|
||||
const { highestImage } = findHighestAttachmentCounters(options.prompt())
|
||||
const count = highestImage + 1
|
||||
setImageCount(count)
|
||||
|
||||
const placeholder = formatImagePlaceholder(count)
|
||||
const textarea = options.getTextarea()
|
||||
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
} else {
|
||||
options.setPrompt(options.prompt() + placeholder)
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64Data = (reader.result as string).split(",")[1]
|
||||
const display = formatImagePlaceholder(count)
|
||||
const filename = `image-${count}.png`
|
||||
|
||||
const attachment = createFileAttachment(
|
||||
@@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
options.instanceFolder(),
|
||||
)
|
||||
attachment.url = `data:image/png;base64,${base64Data}`
|
||||
attachment.display = display
|
||||
attachment.display = placeholder
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
|
||||
const textarea = options.getTextarea()
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const placeholder = formatImagePlaceholder(count)
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
|
||||
@@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
if (isLongPaste) {
|
||||
e.preventDefault()
|
||||
|
||||
const count = pasteCount() + 1
|
||||
const { highestPaste } = findHighestAttachmentCounters(options.prompt())
|
||||
const count = highestPaste + 1
|
||||
setPasteCount(count)
|
||||
|
||||
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
||||
@@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
const filename = `paste-${count}.txt`
|
||||
|
||||
const attachment = createTextAttachment(pastedText, display, filename)
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
|
||||
const placeholder = formatPastedPlaceholder(count)
|
||||
const textarea = options.getTextarea()
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const placeholder = formatPastedPlaceholder(count)
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
@@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
} else {
|
||||
options.setPrompt(options.prompt() + placeholder)
|
||||
}
|
||||
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<kbd class="kbd ml-2">
|
||||
<kbd class="kbd ml-2 kbd-hint">
|
||||
Cmd+Enter
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
@@ -299,13 +299,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
/>
|
||||
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
<PromptAttachmentsBar
|
||||
attachments={attachments()}
|
||||
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
|
||||
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={attachments().length > 0}>
|
||||
<PromptAttachmentsBar
|
||||
attachments={attachments()}
|
||||
onRemoveAttachment={(attachmentId) => {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.removeAttachment(attachmentId)
|
||||
return
|
||||
}
|
||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||
}}
|
||||
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Command {
|
||||
description: Resolvable<string>
|
||||
keywords?: Resolvable<string[]>
|
||||
shortcut?: KeyboardShortcut
|
||||
disabled?: Resolvable<boolean>
|
||||
action: () => void | Promise<void>
|
||||
category?: Resolvable<string>
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getLogger } from "../logger"
|
||||
import { requestData } from "../opencode-api"
|
||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -28,6 +29,7 @@ function splitKeywords(key: string): string[] {
|
||||
export interface UseCommandsOptions {
|
||||
preferences: Accessor<Preferences>
|
||||
toggleShowThinkingBlocks: () => void
|
||||
toggleKeyboardShortcutHints: () => void
|
||||
toggleShowTimelineTools: () => void
|
||||
toggleUsageMetrics: () => void
|
||||
toggleAutoCleanupBlankSessions: () => void
|
||||
@@ -454,6 +456,26 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
action: options.toggleShowTimelineTools,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "keyboard-shortcut-hints",
|
||||
label: () =>
|
||||
tGlobal(
|
||||
options.preferences().showKeyboardShortcutHints
|
||||
? "commands.keyboardShortcutHints.label.hide"
|
||||
: "commands.keyboardShortcutHints.label.show",
|
||||
),
|
||||
description: () =>
|
||||
tGlobal(
|
||||
runtimeEnv.host === "web"
|
||||
? "commands.keyboardShortcutHints.description.disabledWeb"
|
||||
: "commands.keyboardShortcutHints.description",
|
||||
),
|
||||
category: "System",
|
||||
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
||||
disabled: () => runtimeEnv.host === "web",
|
||||
action: options.toggleKeyboardShortcutHints,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "thinking-default-visibility",
|
||||
label: () => {
|
||||
|
||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
||||
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
|
||||
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
|
||||
|
||||
"commands.keyboardShortcutHints.label.show": "Show Keyboard Shortcut Hints",
|
||||
"commands.keyboardShortcutHints.label.hide": "Hide Keyboard Shortcut Hints",
|
||||
"commands.keyboardShortcutHints.description": "Show or hide keyboard shortcut hints across the UI",
|
||||
"commands.keyboardShortcutHints.description.disabledWeb": "Disabled in WebUI (shortcut hints are always hidden)",
|
||||
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, hints",
|
||||
|
||||
"commands.common.expanded": "Expanded",
|
||||
"commands.common.collapsed": "Collapsed",
|
||||
"commands.common.visible": "Visible",
|
||||
|
||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
||||
"commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes",
|
||||
"commands.timelineToolCalls.keywords": "línea de tiempo, herramienta, alternar",
|
||||
|
||||
"commands.keyboardShortcutHints.label.show": "Mostrar atajos de teclado",
|
||||
"commands.keyboardShortcutHints.label.hide": "Ocultar atajos de teclado",
|
||||
"commands.keyboardShortcutHints.description": "Mostrar u ocultar sugerencias de atajos de teclado en la interfaz",
|
||||
"commands.keyboardShortcutHints.description.disabledWeb": "Desactivado en WebUI (los atajos siempre se ocultan)",
|
||||
"commands.keyboardShortcutHints.keywords": "atajo, atajos, teclado, keybind, pistas",
|
||||
|
||||
"commands.common.expanded": "Expandido",
|
||||
"commands.common.collapsed": "Colapsado",
|
||||
"commands.common.visible": "Visible",
|
||||
|
||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
||||
"commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages",
|
||||
"commands.timelineToolCalls.keywords": "timeline, outil, basculer",
|
||||
|
||||
"commands.keyboardShortcutHints.label.show": "Afficher les raccourcis clavier",
|
||||
"commands.keyboardShortcutHints.label.hide": "Masquer les raccourcis clavier",
|
||||
"commands.keyboardShortcutHints.description": "Afficher ou masquer les indices de raccourcis clavier dans l'interface",
|
||||
"commands.keyboardShortcutHints.description.disabledWeb": "Désactivé en WebUI (les raccourcis sont toujours masqués)",
|
||||
"commands.keyboardShortcutHints.keywords": "raccourci, raccourcis, clavier, keybind, indices",
|
||||
|
||||
"commands.common.expanded": "Développé",
|
||||
"commands.common.collapsed": "Réduit",
|
||||
"commands.common.visible": "Visible",
|
||||
|
||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
||||
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
|
||||
"commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle",
|
||||
|
||||
"commands.keyboardShortcutHints.label.show": "キーボードショートカットのヒントを表示",
|
||||
"commands.keyboardShortcutHints.label.hide": "キーボードショートカットのヒントを非表示",
|
||||
"commands.keyboardShortcutHints.description": "UI 全体のキーボードショートカットヒントを表示/非表示",
|
||||
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI では無効(ヒントは常に非表示)",
|
||||
"commands.keyboardShortcutHints.keywords": "ショートカット, キーボード, ヒント, shortcuts, keyboard, hints",
|
||||
|
||||
"commands.common.expanded": "展開",
|
||||
"commands.common.collapsed": "折りたたみ",
|
||||
"commands.common.visible": "表示",
|
||||
|
||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
||||
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
|
||||
"commands.timelineToolCalls.keywords": "таймлайн, tool, переключить",
|
||||
|
||||
"commands.keyboardShortcutHints.label.show": "Показать подсказки сочетаний",
|
||||
"commands.keyboardShortcutHints.label.hide": "Скрыть подсказки сочетаний",
|
||||
"commands.keyboardShortcutHints.description": "Показать или скрыть подсказки сочетаний клавиш в интерфейсе",
|
||||
"commands.keyboardShortcutHints.description.disabledWeb": "Отключено в WebUI (подсказки всегда скрыты)",
|
||||
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, подсказки",
|
||||
|
||||
"commands.common.expanded": "Развернуто",
|
||||
"commands.common.collapsed": "Свернуто",
|
||||
"commands.common.visible": "Видимо",
|
||||
|
||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
||||
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
|
||||
"commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换",
|
||||
|
||||
"commands.keyboardShortcutHints.label.show": "显示键盘快捷键提示",
|
||||
"commands.keyboardShortcutHints.label.hide": "隐藏键盘快捷键提示",
|
||||
"commands.keyboardShortcutHints.description": "显示或隐藏界面中的键盘快捷键提示",
|
||||
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI 中已禁用(提示始终隐藏)",
|
||||
"commands.keyboardShortcutHints.keywords": "shortcuts, keyboard, hints, 快捷键, 键盘, 提示",
|
||||
|
||||
"commands.common.expanded": "展开",
|
||||
"commands.common.collapsed": "折叠",
|
||||
"commands.common.visible": "可见",
|
||||
|
||||
@@ -34,6 +34,7 @@ export type ListeningMode = "local" | "all"
|
||||
|
||||
export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
showKeyboardShortcutHints: boolean
|
||||
thinkingBlocksExpansion: ExpansionPreference
|
||||
showTimelineTools: boolean
|
||||
promptSubmitOnEnter: boolean
|
||||
@@ -78,6 +79,7 @@ const MAX_FAVORITE_MODELS = 50
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
showKeyboardShortcutHints: true,
|
||||
thinkingBlocksExpansion: "expanded",
|
||||
showTimelineTools: true,
|
||||
promptSubmitOnEnter: false,
|
||||
@@ -131,6 +133,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
|
||||
return {
|
||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
showKeyboardShortcutHints: sanitized.showKeyboardShortcutHints ?? defaultPreferences.showKeyboardShortcutHints,
|
||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
|
||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultPreferences.promptSubmitOnEnter,
|
||||
@@ -393,6 +396,10 @@ function toggleShowThinkingBlocks(): void {
|
||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||
}
|
||||
|
||||
function toggleKeyboardShortcutHints(): void {
|
||||
updatePreferences({ showKeyboardShortcutHints: !preferences().showKeyboardShortcutHints })
|
||||
}
|
||||
|
||||
function toggleShowTimelineTools(): void {
|
||||
updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
|
||||
}
|
||||
@@ -511,6 +518,7 @@ interface ConfigContextValue {
|
||||
setThemePreference: typeof setThemePreference
|
||||
updateConfig: typeof updateConfig
|
||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||
toggleKeyboardShortcutHints: typeof toggleKeyboardShortcutHints
|
||||
toggleShowTimelineTools: typeof toggleShowTimelineTools
|
||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||
@@ -548,6 +556,7 @@ const configContextValue: ConfigContextValue = {
|
||||
setThemePreference,
|
||||
updateConfig,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
@@ -608,6 +617,7 @@ export {
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
|
||||
@@ -46,6 +46,11 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-item:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,19 @@
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
/*
|
||||
Shortcut hints are useful on desktop native apps, but are noisy/irrelevant on
|
||||
touch-first devices and in WebUI where browser shortcuts often conflict.
|
||||
*/
|
||||
html[data-runtime-host="web"] .keyboard-hints,
|
||||
html[data-runtime-host="web"] .kbd-hint,
|
||||
html[data-runtime-platform="mobile"] .keyboard-hints,
|
||||
html[data-runtime-platform="mobile"] .kbd-hint,
|
||||
html[data-keyboard-hints="hide"] .keyboard-hints,
|
||||
html[data-keyboard-hints="hide"] .kbd-hint {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Truncate from the start (keeps end visible; good for paths) */
|
||||
.truncate-start {
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user