Release v0.18.0 - Compact mobile UI, tool expansion controls, smoother streaming, session fixes and Winget automation (#568)
Some checks failed
Release Binaries / release (push) Has been cancelled

## Thanks for contributions

- [@shantur](https://github.com/shantur):
[#535](https://github.com/NeuralNomadsAI/CodeNomad/pull/535),
[#556](https://github.com/NeuralNomadsAI/CodeNomad/pull/556),
[#557](https://github.com/NeuralNomadsAI/CodeNomad/pull/557),
[#562](https://github.com/NeuralNomadsAI/CodeNomad/pull/562),
[#563](https://github.com/NeuralNomadsAI/CodeNomad/pull/563),
[#564](https://github.com/NeuralNomadsAI/CodeNomad/pull/564),
[#565](https://github.com/NeuralNomadsAI/CodeNomad/pull/565),
[#567](https://github.com/NeuralNomadsAI/CodeNomad/pull/567)
- [@pascalandr](https://github.com/pascalandr):
[#533](https://github.com/NeuralNomadsAI/CodeNomad/pull/533),
[#551](https://github.com/NeuralNomadsAI/CodeNomad/pull/551)
- [@JDis03](https://github.com/JDis03):
[#484](https://github.com/NeuralNomadsAI/CodeNomad/pull/484),
[#536](https://github.com/NeuralNomadsAI/CodeNomad/pull/536)
- [@aayurt](https://github.com/aayurt):
[#552](https://github.com/NeuralNomadsAI/CodeNomad/pull/552)

## Highlights

- **Compact mode is much better on mobile**: The message UI has been
redesigned for compact layouts, with cleaner controls and a denser
conversation view that works better on smaller screens.
- **Tool output is easier to control**: New tool-level expansion
settings let you choose how much tool-call detail you want to see,
making busy sessions easier to scan.
- **Streaming output feels steadier**: Bash output, tool output, and
assistant responses now stay pinned more reliably while streaming,
reducing jumps and missed updates.
- **Project sessions are scoped more accurately**: Session lists now
respect the active project more consistently, so switching projects is
less likely to show unrelated sessions.
- **Tab closing is more dependable**: Closing tabs has been tightened up
to avoid cases where the wrong tab behavior could interrupt your
workflow.

## What’s Improved

- **Cleaner compact conversation layout**: Message blocks, message
sections, tool calls, permissions, questions, todos, and task output
have all been polished for a more readable compact experience.
- **More useful tool-call presentation**: Tool calls now have a stronger
registry-backed rendering path, clearer titles, improved status display,
and better per-tool output handling.
- **Better follow behavior near the bottom of sessions**: The session
view now tracks bottom-follow intent more carefully, helping the app
stay with new output when you expect it to.
- **Smoother live session creation**: Session-created events from the
live event stream are now handled directly, so new sessions appear more
reliably without manual refreshes.
- **Improved permission denial feedback**: Denied permissions now
produce simpler, less noisy feedback in the conversation.

## Fixes

- **More reliable autoscroll during long responses**: Hold-mode
assistant autoscroll, message-part tool output autoscroll, and streaming
bash output pinning were stabilized.
- **Less duplicated streaming text**: SSE part deltas are throttled and
buffered more carefully to prevent stale delta text from being
duplicated in streamed messages.
- **Better session list isolation**: Project session list requests are
now scoped so sessions from one project do not bleed into another
project’s list.
- **More stable tab management**: A tab close bug was fixed to make
multi-session navigation safer.
- **Release pipeline reliability**: Winget release automation is now
wired into the release workflow so Windows package updates can be
published more consistently.

## Docs

- **New contributor guide**: A `CONTRIBUTING.md` guide was added to help
new contributors set up the project and understand the development
workflow.
- **Winget release docs updated**: Supporting documentation for Winget
release automation was refreshed.

### Contributors

- [@shantur](https://github.com/shantur)
- [@pascalandr](https://github.com/pascalandr)
- [@JDis03](https://github.com/JDis03)
- [@aayurt](https://github.com/aayurt)

**Full Changelog**:
[v0.17.0...v0.18.0](https://github.com/NeuralNomadsAI/CodeNomad/compare/v0.17.0...v0.18.0)

## Merged Pull Requests

### [@shantur](https://github.com/shantur)

- PR [#535](https://github.com/NeuralNomadsAI/CodeNomad/pull/535)
“fix(ui): simplify permission denial feedback”
- PR [#556](https://github.com/NeuralNomadsAI/CodeNomad/pull/556)
“fix(ui): keep streaming bash output pinned”
- PR [#557](https://github.com/NeuralNomadsAI/CodeNomad/pull/557) “WIP:
Compact mode message UI redesign”
- PR [#562](https://github.com/NeuralNomadsAI/CodeNomad/pull/562)
“fix(ui): handle session created SSE events”
- PR [#563](https://github.com/NeuralNomadsAI/CodeNomad/pull/563)
“feat(ui): Add tool level expansion settings.”
- PR [#564](https://github.com/NeuralNomadsAI/CodeNomad/pull/564)
“Improve compact mobile UI controls”
- PR [#565](https://github.com/NeuralNomadsAI/CodeNomad/pull/565)
“fix(ui): scope project session list requests”
- PR [#567](https://github.com/NeuralNomadsAI/CodeNomad/pull/567)
“fix(ui): keep message-part tool output auto-scroll synced”

### [@pascalandr](https://github.com/pascalandr)

- PR [#533](https://github.com/NeuralNomadsAI/CodeNomad/pull/533) “fix:
stabilize hold-mode assistant autoscroll”
- PR [#551](https://github.com/NeuralNomadsAI/CodeNomad/pull/551) “fix:
wire Winget automation into release pipeline”

### [@JDis03](https://github.com/JDis03)

- PR [#484](https://github.com/NeuralNomadsAI/CodeNomad/pull/484) “docs:
add CONTRIBUTING.md guide for new contributors”
- PR [#536](https://github.com/NeuralNomadsAI/CodeNomad/pull/536)
“fix(ui): throttle SSE part deltas and prevent stale delta text
duplication”

### [@aayurt](https://github.com/aayurt)

- PR [#552](https://github.com/NeuralNomadsAI/CodeNomad/pull/552)
“Bugfix/tab close bug”
This commit is contained in:
Shantur Rathore 2026-06-21 18:26:38 +01:00 committed by GitHub
commit 6fe942fca9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 5696 additions and 3002 deletions

View file

@ -97,6 +97,18 @@ jobs:
release_name: ${{ needs.prepare-release.outputs.release_name }}
secrets: inherit
update-winget:
needs:
- prepare-release
- build-and-upload
if: ${{ !inputs.prerelease }}
permissions:
contents: read
uses: ./.github/workflows/update-winget.yml
with:
release_tag: ${{ needs.prepare-release.outputs.tag }}
secrets: inherit
release-ui:
needs: prepare-release
if: ${{ inputs.release_ui }}

View file

@ -1,17 +1,83 @@
name: Update Winget
on:
release:
types:
- published
workflow_call:
inputs:
release_tag:
description: "Stable release tag to inspect"
required: true
type: string
release_id:
description: "Optional numeric GitHub release id"
required: false
default: ""
type: string
workflow_dispatch:
inputs:
release_tag:
description: "Stable release tag to inspect"
required: true
type: string
release_id:
description: "Optional numeric GitHub release id"
required: false
default: ""
type: string
permissions:
contents: read
jobs:
resolve-release:
name: Resolve release metadata
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.release.outputs.release_id }}
release_tag: ${{ steps.release.outputs.release_tag }}
draft: ${{ steps.release.outputs.draft }}
prerelease: ${{ steps.release.outputs.prerelease }}
steps:
- name: Resolve release metadata
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_ID_INPUT: ${{ inputs.release_id }}
run: |
set -euo pipefail
if [ -n "$RELEASE_ID_INPUT" ]; then
release_api="repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID_INPUT}"
else
release_api="repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}"
fi
release_id="$(gh api "$release_api" --jq '.id')"
release_tag="$(gh api "$release_api" --jq '.tag_name')"
draft="$(gh api "$release_api" --jq '.draft')"
prerelease="$(gh api "$release_api" --jq '.prerelease')"
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
echo "Unable to resolve release id for tag '$RELEASE_TAG'" >&2
exit 1
fi
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "draft=$draft" >> "$GITHUB_OUTPUT"
echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
- name: Log resolved release metadata
run: |
echo "Release tag: ${{ steps.release.outputs.release_tag }}"
echo "Release id: ${{ steps.release.outputs.release_id }}"
echo "Draft: ${{ steps.release.outputs.draft }}"
echo "Prerelease: ${{ steps.release.outputs.prerelease }}"
update-winget:
name: Submit Winget manifest update
if: ${{ !github.event.release.draft && !github.event.release.prerelease }}
needs: resolve-release
if: ${{ needs.resolve-release.outputs.draft != 'true' && needs.resolve-release.outputs.prerelease != 'true' }}
runs-on: ubuntu-latest
env:
WINGET_PACKAGE_IDENTIFIER: ${{ vars.WINGET_PACKAGE_IDENTIFIER || 'NeuralNomadsAI.CodeNomad' }}
@ -35,8 +101,8 @@ jobs:
run: |
args=(
--repo "${{ github.repository }}"
--release-id "${{ github.event.release.id }}"
--tag "${{ github.event.release.tag_name }}"
--release-id "${{ needs.resolve-release.outputs.release_id }}"
--tag "${{ needs.resolve-release.outputs.release_tag }}"
--asset-name-template "$WINGET_WINDOWS_ASSET_NAME_TEMPLATE"
--timeout-seconds "$WINGET_ASSET_WAIT_TIMEOUT_SECONDS"
--poll-interval-seconds "$WINGET_ASSET_POLL_INTERVAL_SECONDS"
@ -79,7 +145,7 @@ jobs:
with:
identifier: ${{ env.WINGET_PACKAGE_IDENTIFIER }}
version: ${{ steps.release_asset.outputs.version }}
release-tag: ${{ github.event.release.tag_name }}
release-tag: ${{ needs.resolve-release.outputs.release_tag }}
installers-regex: ${{ steps.release_asset.outputs.asset_regex }}
fork-user: ${{ env.WINGET_FORK_OWNER }}
token: ${{ secrets.WINGET_GITHUB_TOKEN }}

View file

@ -6,6 +6,7 @@
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.
- Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size.
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
- Never use rounded corners in UI styling; keep corners square unless the user explicitly requests otherwise for a specific change.
- Document any new styling conventions or directory additions in this file so future changes remain consistent.
## Coding Principles

154
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,154 @@
# Contributing to CodeNomad
Thank you for your interest in contributing! This guide will help you get started.
## Prerequisites
- **Node.js 18+** and npm
- **OpenCode CLI** in your `PATH` (the server connects to the OpenCode binary to manage workspaces)
## Quick Start
```bash
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
cd CodeNomad
npm install
npm run dev
```
## Finding Issues to Work On
Browse [open issues](https://github.com/NeuralNomadsAI/CodeNomad/issues) and look for these labels:
| Label | Meaning |
|---|---|
| `ready-to-work` | Clear scope, ready for anyone to pick up |
| `good-first-issue` | Good for first-time contributors |
| `enhancement` | New feature requests |
| `bug` | Bug reports |
**Before starting:** comment on the issue so we can discuss approach and avoid duplicate work.
## Development Workflow
### 1. Fork and Branch
```bash
# Fork the repo on GitHub, then clone your fork
git clone https://github.com/YOUR_USERNAME/CodeNomad.git
cd CodeNomad
# Add the upstream remote
git remote add upstream https://github.com/NeuralNomadsAI/CodeNomad.git
# Create a branch from upstream/dev
git fetch upstream
git checkout -b fix/your-branch-name upstream/dev
```
### 2. Branch Naming
| Prefix | Use for |
|---|---|
| `fix/` | Bug fixes |
| `feat/` | New features |
| `docs/` | Documentation changes |
| `refactor/` | Code refactoring |
| `chore/` | Build, config, maintenance |
Examples: `fix/question-queue-ordering`, `feat/retry-tool-call`, `docs/contributing-guide`
### 3. Make Your Changes
```bash
# Install dependencies
npm install
# Run the dev server
npm run dev
# Run type checking
npm run typecheck --workspace @codenomad/ui
```
### 4. Commit
Write clear, descriptive commit messages. Explain **what** changed and **why**.
```bash
git add .
git commit -m "fix(ui): preserve question queue order when upserting duplicate requests
When a question arrives as a global entry and later resolves to a tool
part with a newer timestamp, the original enqueue time was lost, causing
the question to move behind newer entries and break interruption order."
```
### 5. Push and Create a PR
```bash
git push origin your-branch-name
```
Then open a pull request on GitHub targeting the `dev` branch.
**PR checklist:**
- [ ] Branch is based on latest `upstream/dev`
- [ ] One issue per PR (don't mix unrelated changes)
- [ ] Type checking passes: `npm run typecheck` (root) or the workspace-specific script matching your change area
- [ ] Tests pass (if applicable)
- [ ] PR description explains the change, includes relevant screenshots for UI changes, and links related issues when applicable
## Project Structure
| Package | Description |
|---|---|
| `packages/server` | Core logic & CLI — workspaces, OpenCode proxy, API, auth |
| `packages/ui` | SolidJS frontend — reactive UI components and stores |
| `packages/electron-app` | Electron desktop shell |
| `packages/tauri-app` | Tauri desktop shell (experimental) |
| `packages/opencode-plugin` | OpenCode plugin integration |
| `packages/cloudflare` | Cloudflare deployment adapters |
### Key UI Files
| Path | Purpose |
|---|---|
| `packages/ui/src/stores/session-events.ts` | SSE event handlers (idle, status, permissions, questions) |
| `packages/ui/src/stores/session-actions.ts` | User actions (send message, abort, revert, fork) |
| `packages/ui/src/stores/message-v2/` | Message store (v2 architecture) |
| `packages/ui/src/stores/instances.ts` | Instance management and interruption queues |
| `packages/ui/src/components/tool-call.tsx` | Tool call rendering |
| `packages/ui/src/components/message-block.tsx` | Message display blocks |
| `packages/ui/src/components/session/session-view.tsx` | Main session view |
| `packages/ui/src/lib/i18n/messages/` | Translation files (en, es, fr, ja, ru, he, zh-Hans) |
> For a comprehensive map of all six functional areas (server, UI, desktop, speech/audio, build, Cloudflare), SDK integration patterns, and feature traces, load the `codenomad-architecture-guide` skill:
> `.opencode/skills/codenomad-architecture-guide/SKILL.md`
### Styling
- Tokens: `packages/ui/src/styles/tokens.css`
- Utilities: `packages/ui/src/styles/utilities.css`
- Component styles: `packages/ui/src/styles/components/`, `packages/ui/src/styles/messaging/`, `packages/ui/src/styles/panels/`
- Keep style files under ~150 lines; split by component
### Internationalization (i18n)
- Use `useI18n()` in components, `tGlobal()` in stores
- Messages live in `packages/ui/src/lib/i18n/messages/<locale>/`
- When adding a string: add to `en/` first, then add the same key to every other locale
- Placeholders use `{name}` syntax (word characters only)
## Code Principles
- **KISS**: Keep modules narrowly scoped
- **DRY**: Share helpers before copy-pasting
- **Single responsibility**: Split files when concerns diverge
- **Composable primitives**: Prefer signals, hooks, utilities over deep inheritance
## Need Help?
- Check existing [issues](https://github.com/NeuralNomadsAI/CodeNomad/issues) and [PRs](https://github.com/NeuralNomadsAI/CodeNomad/pulls)
- Ask in the issue you're working on
- Review the [server documentation](packages/server/README.md) for CLI flags and configuration

View file

@ -1,11 +1,13 @@
# Winget release automation
CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/update-winget.yml`.
CodeNomad publishes Winget updates from the stable GitHub release pipeline. `.github/workflows/reusable-release.yml` now calls `.github/workflows/update-winget.yml` after the release assets finish uploading.
## Trigger
- Runs on `release.published`.
- Exits early for draft or prerelease releases.
- Runs as a reusable workflow from the stable release pipeline, after `build-and-upload` completes.
- Resolves the target release by tag through the GitHub API, then exits early for draft or prerelease releases.
- Can also be rerun manually with `workflow_dispatch` by supplying the stable release tag (and optionally the numeric release id).
- This avoids the old `release.published` trap where a release created by GitHub Actions with the default `GITHUB_TOKEN` does not fan out into a second workflow run.
- Waits for the expected stable Windows Tauri asset because the release record can exist before all assets finish uploading.
## Required configuration
@ -28,7 +30,7 @@ CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/
## Runtime flow
1. Resolve the release version from `github.event.release.tag_name`.
1. Resolve the target release by tag through the GitHub API, then derive the package version from the resolved release tag.
2. Poll the release API until exactly one uploaded asset matches the configured Windows Tauri asset template.
3. Download the matched asset once and compute a SHA-256 for logging and verification.
4. Verify the PAT owner matches `WINGET_FORK_OWNER` and that `${WINGET_FORK_OWNER}/winget-pkgs` is a fork of `microsoft/winget-pkgs`.
@ -38,3 +40,4 @@ CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/
- The workflow does not depend on a persistent local `winget-pkgs` clone.
- The current package in `winget-pkgs` uses the release ZIP with a nested NSIS installer, so the workflow targets the stable `CodeNomad-Tauri-windows-x64-{version}.zip` asset.
- If a maintainer publishes a release outside the standard release workflow, they should manually run `Update Winget` for that stable tag.

22
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.17.0",
"version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.17.0",
"version": "0.18.0",
"license": "MIT",
"dependencies": {
"7zip-bin": "^5.2.0",
@ -3266,9 +3266,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.16.0.tgz",
"integrity": "sha512-S4H2e9j4rdHs5BQOCjmVEdqdXmKwPFKjXPbPUaWiRJpAjBcZ/uIBpoZkmV+x9BLzc+vrE6WAffMZieQgukt4DA==",
"version": "1.17.8",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.8.tgz",
"integrity": "sha512-6MKmsj2ujZyL44jy+12dpwWYDYKPS9fUr+0wVQxaIlPYQ/eAt8T8T3QrybplJ5ZtHfZUX+esXZ02x2UYYm7oEw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
@ -13409,7 +13409,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.17.0",
"version": "0.18.0",
"license": "MIT",
"dependencies": {
"yaml": "^2.4.2"
@ -13436,7 +13436,7 @@
},
"packages/opencode-plugin": {
"name": "@codenomad/codenomad-opencode-plugin",
"version": "0.17.0",
"version": "0.18.0",
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.3.7"
@ -13448,7 +13448,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.17.0",
"version": "0.18.0",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@ -13490,7 +13490,7 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.17.0",
"version": "0.18.0",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
@ -13498,12 +13498,12 @@
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.17.0",
"version": "0.18.0",
"license": "MIT",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.16.0",
"@opencode-ai/sdk": "^1.17.8",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View file

@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.17.0",
"version": "0.18.0",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.17.0",
"version": "0.18.0",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {

View file

@ -1,6 +1,6 @@
{
"name": "@codenomad/codenomad-opencode-plugin",
"version": "0.17.0",
"version": "0.18.0",
"private": true,
"license": "MIT",
"type": "module",

View file

@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.17.0",
"version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.17.0",
"version": "0.18.0",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^11.2.0",

View file

@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.17.0",
"version": "0.18.0",
"description": "CodeNomad Server",
"license": "MIT",
"author": {

View file

@ -497,7 +497,7 @@ dependencies = [
[[package]]
name = "codenomad-tauri"
version = "0.17.0"
version = "0.18.0"
dependencies = [
"anyhow",
"base64 0.22.1",

View file

@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.17.0",
"version": "0.18.0",
"private": true,
"license": "MIT",
"scripts": {

View file

@ -1,6 +1,6 @@
[package]
name = "codenomad-tauri"
version = "0.17.0"
version = "0.18.0"
edition = "2021"
license = "MIT"

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.17.0",
"version": "0.18.0",
"identifier": "ai.neuralnomads.codenomad.client",
"build": {
"beforeDevCommand": "npm run dev:bootstrap",

View file

@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.17.0",
"version": "0.18.0",
"private": true,
"license": "MIT",
"type": "module",
@ -13,7 +13,7 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.16.0",
"@opencode-ai/sdk": "^1.17.8",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View file

@ -47,7 +47,6 @@ import {
clearActiveParentSession,
createSession,
fetchSessions,
getSessionFetchLimit,
updateSessionAgent,
updateSessionModel,
} from "./stores/sessions"
@ -389,7 +388,7 @@ const App: Component = () => {
if (!confirmed) return
await stopInstance(instanceId)
stopInstance(instanceId)
}
async function handleNewSession(instanceId: string) {
@ -419,7 +418,7 @@ const App: Component = () => {
clearActiveParentSession(instanceId)
try {
await fetchSessions(instanceId, { reset: true, limit: getSessionFetchLimit(instanceId) })
await fetchSessions(instanceId, { reset: true })
} catch (error) {
log.error("Failed to refresh sessions after closing", error)
}

View file

@ -7,6 +7,7 @@ export interface ActionOverflowMenuItem {
label: string
icon?: JSXElement
disabled?: boolean
checked?: boolean
destructive?: boolean
onSelect: () => void | Promise<void>
onMouseEnter?: () => void
@ -52,6 +53,8 @@ export default function ActionOverflowMenu(props: ActionOverflowMenuProps) {
<DropdownMenu.Item
class="action-overflow-item"
data-destructive={item.destructive ? "true" : undefined}
role={typeof item.checked === "boolean" ? "menuitemcheckbox" : undefined}
aria-checked={typeof item.checked === "boolean" ? item.checked : undefined}
disabled={item.disabled}
onPointerEnter={() => {
if (item.disabled) return

View file

@ -7,6 +7,7 @@ interface ContextMeterProps {
usedLabel: string
availableLabel: string
class?: string
centerValue?: boolean
}
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
@ -104,18 +105,32 @@ export const ContextMeter: Component<ContextMeterProps> = (props) => {
const tooltipText = () => `Context Used: ${percentLabel()}`
const valuePill = () => (
<div class={containerClass}>
<span class={LABEL_CLASS}>{props.usedLabel}</span>
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
<span class="text-muted">/</span>
<span class={LABEL_CLASS}>{props.availableLabel}</span>
<span class="font-semibold text-primary tabular-nums">
{available() !== null ? props.formatTokens(available() as number) : "--"}
</span>
</div>
)
if (props.centerValue) {
return (
<div class="grid grid-cols-[22px_auto_22px] items-center gap-2" title={tooltipText()}>
<div class="flex justify-end">{circle()}</div>
{valuePill()}
<span aria-hidden="true" />
</div>
)
}
return (
<div class="inline-flex items-center gap-2" title={tooltipText()}>
{circle()}
<div class={containerClass}>
<span class={LABEL_CLASS}>{props.usedLabel}</span>
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
<span class="text-muted">/</span>
<span class={LABEL_CLASS}>{props.availableLabel}</span>
<span class="font-semibold text-primary tabular-nums">
{available() !== null ? props.formatTokens(available() as number) : "--"}
</span>
</div>
{valuePill()}
</div>
)
}

View file

@ -1,4 +1,4 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Dynamic } from "solid-js/web"
import {
DragDropProvider,
@ -37,15 +37,9 @@ interface SortableAppTabProps {
onClose: (tabId: string) => void
}
const SortableAppTab: Component<SortableAppTabProps> = (props) => {
const sortable = createSortable(props.tab.id)
const AppTabContent: Component<SortableAppTabProps> = (props) => {
return (
<div
ref={sortable}
class={`tab-draggable ${sortable.isActiveDraggable ? "tab-draggable-active" : ""}`}
data-app-tab-id={props.tab.id}
>
<>
{props.tab.kind === "instance" ? (
<InstanceTab
instance={props.tab.instance}
@ -82,14 +76,59 @@ const SortableAppTab: Component<SortableAppTabProps> = (props) => {
</button>
</div>
)}
</>
)
}
const SortableAppTab: Component<SortableAppTabProps> = (props) => {
const sortable = createSortable(props.tab.id)
return (
<div
ref={sortable}
class={`tab-draggable ${sortable.isActiveDraggable ? "tab-draggable-active" : ""}`}
data-app-tab-id={props.tab.id}
>
<AppTabContent {...props} />
</div>
)
}
const StaticAppTab: Component<SortableAppTabProps> = (props) => {
return (
<div class="tab-draggable" data-app-tab-id={props.tab.id}>
<AppTabContent {...props} />
</div>
)
}
const isTouchOnlyPointer = () => {
if (typeof window === "undefined") return false
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches && !window.matchMedia?.("(any-pointer: fine)")?.matches)
}
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const tabIds = createMemo(() => props.tabs.map((tab) => tab.id))
const [dragReorderEnabled, setDragReorderEnabled] = createSignal(!isTouchOnlyPointer())
onMount(() => {
if (typeof window === "undefined") return
const coarseQuery = window.matchMedia?.("(pointer: coarse)")
const fineQuery = window.matchMedia?.("(any-pointer: fine)")
if (!coarseQuery || !fineQuery) return
const syncDragReorder = () => setDragReorderEnabled(!isTouchOnlyPointer())
syncDragReorder()
coarseQuery.addEventListener("change", syncDragReorder)
fineQuery.addEventListener("change", syncDragReorder)
onCleanup(() => {
coarseQuery.removeEventListener("change", syncDragReorder)
fineQuery.removeEventListener("change", syncDragReorder)
})
})
/** Whether to show toast history panel */
const [showToastHistory, setShowToastHistory] = createSignal(false)
@ -132,22 +171,38 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs">
<DragDropProvider collisionDetector={closestCenter} onDragEnd={handleDragEnd}>
<DragDropSensors>
<SortableProvider ids={tabIds()}>
<For each={props.tabs}>
{(tab) => (
<SortableAppTab
tab={tab}
activeTabId={props.activeTabId}
onSelect={props.onSelect}
onClose={props.onClose}
/>
)}
</For>
</SortableProvider>
</DragDropSensors>
</DragDropProvider>
<Show
when={dragReorderEnabled()}
fallback={
<For each={props.tabs}>
{(tab) => (
<StaticAppTab
tab={tab}
activeTabId={props.activeTabId}
onSelect={props.onSelect}
onClose={props.onClose}
/>
)}
</For>
}
>
<DragDropProvider collisionDetector={closestCenter} onDragEnd={handleDragEnd}>
<DragDropSensors>
<SortableProvider ids={tabIds()}>
<For each={props.tabs}>
{(tab) => (
<SortableAppTab
tab={tab}
activeTabId={props.activeTabId}
onSelect={props.onSelect}
onClose={props.onClose}
/>
)}
</For>
</SortableProvider>
</DragDropSensors>
</DragDropProvider>
</Show>
</div>
<div class="tab-strip-spacer" />
<Show when={props.tabs.length > 1}>

View file

@ -30,6 +30,7 @@ import PermissionApprovalModal from "../permission-approval-modal"
import SessionView from "../session/session-view"
import MessageSection from "../message-section"
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../action-overflow-menu"
import { formatTokenTotal } from "../../lib/formatters"
import ContextMeter from "../context-meter"
import { sseManager } from "../../lib/sse-manager"
@ -156,8 +157,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
const narrowHeaderLayout = createMemo(() => sessionCenterWidthStep() === "narrow")
const compactHeaderLayout = createMemo(() => narrowHeaderLayout() || compactHeaderQuery())
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
const showCompactFullscreenButton = createMemo(() => isPhoneLayout() && !props.mobileFullscreenMode)
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
@ -539,6 +542,24 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
window.dispatchEvent(new CustomEvent(OPEN_SESSION_SEARCH_EVENT))
}
const narrowHeaderMenuItems = createMemo<ActionOverflowMenuItem[]>(() => {
const PreviewIcon = PreviewToggleIcon()
return [
{
key: "search",
label: t("instanceShell.chatSearch.openAriaLabel"),
icon: <Search class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: handleChatSearchClick,
},
{
key: "preview",
label: previewToggleLabel(),
icon: <PreviewIcon class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: handlePreviewButtonClick,
},
]
})
const openBackgroundOutput = (process: BackgroundProcess) => {
setSelectedBackgroundProcess(process)
setShowBackgroundOutput(true)
@ -990,15 +1011,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
when={!compactHeaderLayout()}
fallback={
<div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
{renderHeaderLeftSlot()}
<div class="flex-1 flex items-center justify-center min-w-0">
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-2 w-full">
<div class="flex min-w-0 items-center gap-2">
{renderHeaderLeftSlot()}
{renderSessionHeaderIndicators()}
</div>
<div class="flex flex-wrap items-center justify-center gap-1">
<Show when={!showingInfoView()}>
<Show when={!showingInfoView() && !narrowHeaderLayout()}>
<IconButton
color="inherit"
onClick={handleChatSearchClick}
@ -1023,55 +1043,85 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</span>
</div>
<div class="flex-1 flex items-center justify-center min-w-0">
<div class="flex flex-1 items-center justify-end gap-1 min-w-0">
<span
class={`status-indicator ${connectionStatusClass()}`}
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
>
<span class="status-dot" />
</span>
<Show when={!isPhoneLayout() && !narrowHeaderLayout()}>
{renderPreviewToggleButton()}
</Show>
<Show when={showCompactFullscreenButton() && !narrowHeaderLayout()}>
{renderPreviewToggleButton()}
</Show>
<Show when={rightDrawerState() === "floating-closed"}>
<IconButton
ref={setRightToggleButtonEl}
color="inherit"
onClick={handleRightAppBarButtonClick}
aria-label={rightAppBarButtonLabel()}
size="small"
aria-expanded={rightDrawerState() !== "floating-closed"}
>
{rightAppBarButtonIcon()}
</IconButton>
</Show>
</div>
<Show when={!isPhoneLayout()}>
{renderPreviewToggleButton()}
</Show>
<Show when={isPhoneLayout() && !props.mobileFullscreenMode}>
<IconButton
color="inherit"
onClick={props.onEnterMobileFullscreen}
aria-label={t("instanceShell.fullscreen.enter")}
title={t("instanceShell.fullscreen.enter")}
size="small"
>
<Maximize2 class="w-5 h-5" aria-hidden="true" />
</IconButton>
{renderPreviewToggleButton()}
</Show>
<Show when={rightDrawerState() === "floating-closed"}>
<IconButton
ref={setRightToggleButtonEl}
color="inherit"
onClick={handleRightAppBarButtonClick}
aria-label={rightAppBarButtonLabel()}
size="small"
aria-expanded={rightDrawerState() !== "floating-closed"}
>
{rightAppBarButtonIcon()}
</IconButton>
</Show>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<Show when={!showingInfoView()}>
<ContextMeter
usedTokens={tokenStats().used}
availableTokens={tokenStats().avail}
formatTokens={formatTokenTotal}
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
/>
<div
class={
narrowHeaderLayout() || showCompactFullscreenButton()
? "grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 pb-1"
: "flex flex-wrap items-center justify-center gap-2 pb-1"
}
>
<Show when={narrowHeaderLayout() || showCompactFullscreenButton()}>
<div class="flex min-w-0 items-center justify-start">
<Show when={narrowHeaderLayout() && !showingInfoView()}>
<ActionOverflowMenu
items={narrowHeaderMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="message-action-button"
minItems={1}
/>
</Show>
</div>
</Show>
<div class="flex items-center justify-center">
<Show when={!showingInfoView()}>
<ContextMeter
usedTokens={tokenStats().used}
availableTokens={tokenStats().avail}
formatTokens={formatTokenTotal}
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
centerValue={narrowHeaderLayout() || showCompactFullscreenButton()}
/>
</Show>
</div>
<Show when={narrowHeaderLayout() || showCompactFullscreenButton()}>
<div class="flex items-center justify-end gap-1">
<Show when={showCompactFullscreenButton()}>
<IconButton
color="inherit"
onClick={props.onEnterMobileFullscreen}
aria-label={t("instanceShell.fullscreen.enter")}
title={t("instanceShell.fullscreen.enter")}
size="small"
sx={{ width: 30, height: 30 }}
>
<Maximize2 class="w-5 h-5" aria-hidden="true" />
</IconButton>
</Show>
</div>
</Show>
</div>
</div>

View file

@ -1,5 +1,5 @@
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash, Volume2 } from "lucide-solid"
import { CheckSquare2, Copy, ExternalLink, FoldVertical, ListStart, Square, Trash, Volume2 } from "lucide-solid"
import MessageItem from "./message-item"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message"
@ -15,11 +15,12 @@ import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
import { formatElapsedClock, getMessageStartedAt, getPartStartedAt, inferReasoningDurationMs } from "../lib/message-timing"
import { inferReasoningDurationMs } from "../lib/message-timing"
import type { SessionSearchMatch } from "../lib/session-search"
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu"
import { copyToClipboard } from "../lib/clipboard"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() {
return (
@ -29,10 +30,9 @@ function DeleteUpToIcon() {
)
}
const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
const NO_STEP_BORDER = "none"
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
const LazyToolCall = lazy(() => import("./tool-call"))
@ -391,6 +391,7 @@ function MessageContentItem(props: MessageContentItemProps) {
parts={visibleParts()}
instanceId={props.instanceId}
sessionId={props.sessionId}
contentStartPartId={props.startPartId}
isQueued={isQueued()}
showAgentMeta={showAgentMeta()}
showDeleteMessage={props.showDeleteMessage}
@ -461,7 +462,6 @@ function ToolCallItem(props: ToolCallItemProps) {
})
const toolState = createMemo(() => toolPart()?.state as ToolState | undefined)
const toolName = createMemo(() => toolPart()?.tool || "")
const messageVersion = createMemo(() => record()?.revision ?? 0)
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
@ -480,41 +480,12 @@ function ToolCallItem(props: ToolCallItemProps) {
return findTaskSessionLocation(id, props.instanceId)
})
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const location = taskLocation()
if (!location) return
navigateToTaskSession(location)
}
const goToTaskSession = () => {
const location = taskLocation()
if (!location) return
navigateToTaskSession(location)
}
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (deletingMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
const deleteUpTo = async () => {
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
@ -528,15 +499,23 @@ function ToolCallItem(props: ToolCallItemProps) {
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
await deleteUpTo()
}
const actionMenuItems = (): ActionOverflowMenuItem[] => {
const items: ActionOverflowMenuItem[] = []
if (props.showDeleteMessage) {
items.push({
key: "select",
label: isSelectedForDeletion()
? t("messageItem.selection.deselectForDeletion")
: t("messageItem.selection.selectForDeletion"),
icon: isSelectedForDeletion()
? <CheckSquare2 class="w-3.5 h-3.5" aria-hidden="true" />
: <Square class="w-3.5 h-3.5" aria-hidden="true" />,
checked: isSelectedForDeletion(),
onSelect: () => props.onToggleSelectedMessage?.(props.messageId, !isSelectedForDeletion()),
})
}
if (taskSessionId()) {
items.push({
key: "go-to-session",
@ -593,81 +572,6 @@ function ToolCallItem(props: ToolCallItemProps) {
<Show when={toolPart()}>
{(resolvedToolPart) => (
<div class="delete-hover-scope" data-delete-part-hover={isDeleteOverlayActive() ? "true" : undefined}>
<div class="tool-call-header-label" data-action-overflow={actionMenuItems().length > 1 ? "true" : undefined}>
<div class="tool-call-header-meta">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div>
<div class="tool-call-header-actions flex items-center gap-0">
<Show when={taskSessionId()}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation()}
onClick={handleGoToTaskSession}
title={t("messageBlock.tool.goToSession.label")}
aria-label={t("messageBlock.tool.goToSession.label")}
>
<ExternalLink class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="tool-call-header-button"
type="button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onClick={handleDeleteUpTo}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="tool-call-header-button"
type="button"
disabled={deletingMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
<ActionOverflowMenu
items={actionMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="tool-call-header-button"
minItems={2}
/>
</div>
<Suspense fallback={<ToolCallFallback />}>
<LazyToolCall
toolCall={resolvedToolPart()}
@ -678,6 +582,7 @@ function ToolCallItem(props: ToolCallItemProps) {
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
headerMenuItems={actionMenuItems}
/>
</Suspense>
</div>
@ -898,7 +803,7 @@ export default function MessageBlock(props: MessageBlockProps) {
}
items.push(toolItem)
blockToolKeys.push(key)
lastAccentColor = TOOL_BORDER_COLOR
lastAccentColor = NO_STEP_BORDER
return
}
@ -1171,9 +1076,7 @@ function CompactionCard(props: CompactionCardProps) {
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const deleteMessageNow = async () => {
if (!props.showDeleteMessage) return
if (!canDeleteMessage()) return
setDeletingMessage(true)
@ -1190,9 +1093,7 @@ function CompactionCard(props: CompactionCardProps) {
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const deleteUpTo = async () => {
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
@ -1205,6 +1106,43 @@ function CompactionCard(props: CompactionCardProps) {
}
}
const actionMenuItems = (): ActionOverflowMenuItem[] => {
if (!props.showDeleteMessage) return []
return [
{
key: "select",
label: isSelectedForDeletion()
? t("messageItem.selection.deselectForDeletion")
: t("messageItem.selection.selectForDeletion"),
icon: isSelectedForDeletion()
? <CheckSquare2 class="w-3.5 h-3.5" aria-hidden="true" />
: <Square class="w-3.5 h-3.5" aria-hidden="true" />,
checked: isSelectedForDeletion(),
onSelect: () => props.onToggleSelectedMessage?.(props.messageId, !isSelectedForDeletion()),
},
{
key: "delete-up-to",
label: t("messageItem.actions.deleteMessagesUpTo"),
icon: <DeleteUpToIcon />,
disabled: !props.onDeleteMessagesUpTo || deletingUpTo(),
destructive: true,
onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId }),
onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
onSelect: deleteUpTo,
},
{
key: "delete-message",
label: deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage"),
icon: <Trash class="w-3.5 h-3.5" aria-hidden="true" />,
disabled: !canDeleteMessage(),
destructive: true,
onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId }),
onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
onSelect: deleteMessageNow,
},
]
}
return (
<div
class={`delete-hover-scope ${containerClass()} relative`}
@ -1212,55 +1150,16 @@ function CompactionCard(props: CompactionCardProps) {
role="status"
aria-label={t("messageBlock.compaction.ariaLabel")}
>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<Show when={props.showDeleteMessage}>
<button
type="button"
class="tool-call-header-button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onClick={handleDeleteUpTo}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="tool-call-header-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
<ActionOverflowMenu
items={actionMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="message-action-button"
minItems={1}
/>
</div>
<div class="message-compaction-row">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span>
</div>
@ -1321,7 +1220,14 @@ function StepCard(props: StepCardProps) {
}
}
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const finishStyle = () => {
if (props.borderColor === NO_STEP_BORDER) {
return {
"border-inline-start": "none",
}
}
return props.borderColor ? { "border-left-color": props.borderColor } : undefined
}
let didReportUsageStats = false
createEffect(() => {
@ -1334,9 +1240,7 @@ function StepCard(props: StepCardProps) {
const canDeleteMessage = () =>
Boolean(props.showDeleteMessage && props.instanceId && props.sessionId && props.messageId) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const handleDeleteMessage = async () => {
if (!canDeleteMessage()) return
setDeletingMessage(true)
try {
@ -1352,9 +1256,7 @@ function StepCard(props: StepCardProps) {
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const handleDeleteUpTo = async () => {
if (!props.messageId) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
@ -1367,6 +1269,43 @@ function StepCard(props: StepCardProps) {
}
}
const actionMenuItems = (): ActionOverflowMenuItem[] => {
if (!props.showDeleteMessage || !props.messageId) return []
return [
{
key: "select",
label: isSelectedForDeletion()
? t("messageItem.selection.deselectForDeletion")
: t("messageItem.selection.selectForDeletion"),
icon: isSelectedForDeletion()
? <CheckSquare2 class="w-3.5 h-3.5" aria-hidden="true" />
: <Square class="w-3.5 h-3.5" aria-hidden="true" />,
checked: isSelectedForDeletion(),
onSelect: () => props.onToggleSelectedMessage?.(props.messageId!, !isSelectedForDeletion()),
},
{
key: "delete-up-to",
label: t("messageItem.actions.deleteMessagesUpTo"),
icon: <DeleteUpToIcon />,
disabled: !props.onDeleteMessagesUpTo || deletingUpTo(),
destructive: true,
onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId! }),
onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
onSelect: handleDeleteUpTo,
},
{
key: "delete-message",
label: deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage"),
icon: <Trash class="w-3.5 h-3.5" aria-hidden="true" />,
disabled: !canDeleteMessage(),
destructive: true,
onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! }),
onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
onSelect: handleDeleteMessage,
},
]
}
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [
@ -1398,51 +1337,14 @@ function StepCard(props: StepCardProps) {
}
return (
<div class={`message-step-card message-step-finish message-step-finish-flush relative`} style={finishStyle()}>
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox absolute left-2 top-1/2 -translate-y-1/2"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId!, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<Show when={props.showDeleteMessage}>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
class="message-action-button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onClick={handleDeleteUpTo}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="message-action-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
<ActionOverflowMenu
items={actionMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="message-action-button"
minItems={1}
/>
</div>
</Show>
@ -1456,24 +1358,6 @@ function StepCard(props: StepCardProps) {
<div class="message-step-heading">
<div class="message-step-title">
<div class="message-step-title-left">
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId!, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
@ -1481,7 +1365,17 @@ function StepCard(props: StepCardProps) {
</span>
</Show>
</div>
<span class="message-step-time">{timestamp()}</span>
<span class="message-step-right">
<span class="message-step-time">{timestamp()}</span>
<Show when={props.showDeleteMessage && props.messageId}>
<ActionOverflowMenu
items={actionMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="message-action-button"
minItems={1}
/>
</Show>
</span>
</div>
</div>
</div>
@ -1580,7 +1474,7 @@ function ReasoningStreamOutput(props: {
}
function ReasoningCard(props: ReasoningCardProps) {
const { locale, t } = useI18n()
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
@ -1597,14 +1491,6 @@ function ReasoningCard(props: ReasoningCardProps) {
}
})
const timestamp = () => {
const value = getPartStartedAt(props.part) ?? getMessageStartedAt(props.messageInfo) ?? Date.now()
const date = new Date(value)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const durationLabel = () => formatElapsedClock(props.durationMs, locale())
const agentIdentifier = () => {
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
@ -1620,8 +1506,6 @@ function ReasoningCard(props: ReasoningCardProps) {
return modelID
}
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
const reasoningText = () => {
const part = props.part as any
if (!part) return ""
@ -1657,10 +1541,55 @@ function ReasoningCard(props: ReasoningCardProps) {
return ""
}
const toggle = () => setExpanded((prev) => !prev)
const extractedTitle = () => {
const firstLine = reasoningText()
.split(/\r?\n/)
.map((line: string) => line.trim())
.find((line: string) => line.length > 0)
if (!firstLine) return ""
const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const match = firstLine.match(/^\*\*([^*]+)\*\*/)
return match?.[1]?.trim() ?? ""
}
const thoughtDurationTitle = () => {
const duration = props.durationMs
if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0) {
return t("messageBlock.reasoning.thoughtsFallback")
}
const seconds = Math.max(1, Math.round(duration / 1000))
if (seconds < 60) {
return seconds === 1
? t("messageBlock.reasoning.thoughtFor.seconds.one", { count: String(seconds) })
: t("messageBlock.reasoning.thoughtFor.seconds.other", { count: String(seconds) })
}
const minutes = Math.max(1, Math.round(seconds / 60))
if (minutes < 60) {
return minutes === 1
? t("messageBlock.reasoning.thoughtFor.minutes.one", { count: String(minutes) })
: t("messageBlock.reasoning.thoughtFor.minutes.other", { count: String(minutes) })
}
const hours = Math.max(1, Math.round(minutes / 60))
return hours === 1
? t("messageBlock.reasoning.thoughtFor.hours.one", { count: String(hours) })
: t("messageBlock.reasoning.thoughtFor.hours.other", { count: String(hours) })
}
const reasoningTitle = () => extractedTitle() || thoughtDurationTitle()
const reasoningMetaTooltip = () => {
const parts: string[] = [thoughtDurationTitle()]
const agent = agentIdentifier()
const model = modelIdentifier()
if (agent) parts.push(t("messageBlock.step.agentLabel", { agent }))
if (model) parts.push(t("messageBlock.step.modelLabel", { model }))
return parts.join("\n")
}
const toggle = () => setExpanded((prev) => !prev)
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
@ -1671,6 +1600,12 @@ function ReasoningCard(props: ReasoningCardProps) {
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleCopyReasoning = async () => {
const text = reasoningText()
if (!text.trim()) return
await copyToClipboard(text)
}
const deleteUpTo = async () => {
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
@ -1684,18 +1619,41 @@ function ReasoningCard(props: ReasoningCardProps) {
}
}
const actionMenuItems = (): ActionOverflowMenuItem[] => {
const actionMenuItems = (includePrimaryActions = false): ActionOverflowMenuItem[] => {
const items: ActionOverflowMenuItem[] = []
if (canSpeakReasoning()) {
if (props.showDeleteMessage) {
items.push({
key: "speak",
label: speech.buttonTitle(),
icon: <Volume2 class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => void speech.toggle(),
key: "select",
label: isSelectedForDeletion()
? t("messageItem.selection.deselectForDeletion")
: t("messageItem.selection.selectForDeletion"),
icon: isSelectedForDeletion()
? <CheckSquare2 class="w-3.5 h-3.5" aria-hidden="true" />
: <Square class="w-3.5 h-3.5" aria-hidden="true" />,
checked: isSelectedForDeletion(),
onSelect: () => props.onToggleSelectedMessage?.(props.messageId, !isSelectedForDeletion()),
})
}
if (includePrimaryActions) {
items.push({
key: "copy",
label: t("messageBlock.reasoning.copyTitle"),
icon: <Copy class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: handleCopyReasoning,
})
if (canSpeakReasoning()) {
items.push({
key: "speak",
label: speech.buttonTitle(),
icon: <Volume2 class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => void speech.toggle(),
})
}
}
if (props.showDeleteMessage) {
items.push(
{
@ -1738,31 +1696,6 @@ function ReasoningCard(props: ReasoningCardProps) {
return items
}
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!canDeleteMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
await deleteUpTo()
}
return (
<div
class="delete-hover-scope message-reasoning-card"
@ -1776,35 +1709,30 @@ function ReasoningCard(props: ReasoningCardProps) {
aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
>
<span class="message-reasoning-disclosure" aria-hidden="true">{expanded() ? "▼" : "▶"}</span>
<span class="message-reasoning-label">
<span class="message-reasoning-label-primary">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
<Show when={durationLabel()}>
{(value) => <span class="message-reasoning-duration">{value()}</span>}
</Show>
<span class="message-reasoning-type">{t("messageBlock.reasoning.thinkingLabel")}</span>
<span class="message-reasoning-title" title={reasoningMetaTooltip() || undefined}>
{reasoningTitle()}
</span>
</span>
</button>
<div class="message-reasoning-actions" data-action-overflow={actionMenuItems().length > 1 ? "true" : undefined}>
<div class="message-reasoning-actions" data-action-overflow={actionMenuItems(true).length > 0 ? "true" : undefined}>
<button
type="button"
class="message-action-button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void handleCopyReasoning()
}}
aria-label={t("messageBlock.reasoning.copyAriaLabel")}
title={t("messageBlock.reasoning.copyTitle")}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={canSpeakReasoning()}>
<SpeechActionButton
class="message-action-button"
@ -1819,80 +1747,21 @@ function ReasoningCard(props: ReasoningCardProps) {
/>
</Show>
<button
type="button"
class="message-action-button message-reasoning-primary-action"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggle()
}}
aria-label={viewHideLabel()}
title={viewHideLabel()}
>
<Show when={expanded()} fallback={<ChevronsUpDown class="w-3.5 h-3.5" aria-hidden="true" />}>
<ChevronsDownUp class="w-3.5 h-3.5" aria-hidden="true" />
</Show>
</button>
<Show when={props.showDeleteMessage}>
<button
type="button"
class="message-action-button"
onClick={handleDeleteUpTo}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
title={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="message-action-button"
onClick={handleDeleteMessage}
disabled={!canDeleteMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<ActionOverflowMenu
items={actionMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="message-action-button"
minItems={2}
triggerClass="message-action-button action-overflow-wide"
minItems={1}
/>
<ActionOverflowMenu
items={actionMenuItems(true)}
label={t("messageItem.actions.more")}
triggerClass="message-action-button action-overflow-narrow"
minItems={1}
/>
<div class="message-reasoning-timing">
<span class="message-reasoning-time">{timestamp()}</span>
</div>
</div>
</div>
<Show when={hasMeta()}>
<div class="message-reasoning-meta-row">
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</div>
</Show>
<Show when={expanded()}>
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">

View file

@ -1,6 +1,6 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Portal } from "solid-js/web"
import { Copy, ListStart, Split, Trash, Undo, Volume2 } from "lucide-solid"
import { CheckSquare2, Copy, ListStart, Split, Square, Trash, Undo, Volume2 } from "lucide-solid"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
@ -12,9 +12,9 @@ import { deleteMessage } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu"
import { formatElapsedClock, getMessageDurationMs, getMessageStartedAt } from "../lib/message-timing"
import { getMessageDurationMs, getMessageStartedAt } from "../lib/message-timing"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() {
return (
@ -37,13 +37,14 @@ interface MessageItemProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void
showAgentMeta?: boolean
contentStartPartId?: string
onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
}
export default function MessageItem(props: MessageItemProps) {
const { locale, t } = useI18n()
const { t } = useI18n()
const [copied, setCopied] = createSignal(false)
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
@ -107,7 +108,7 @@ export default function MessageItem(props: MessageItemProps) {
let metaMeasureEl: HTMLSpanElement | undefined
const [showMetaInline, setShowMetaInline] = createSignal(true)
const metaText = () => agentMeta()
const metaText = () => ""
const updateMetaLayout = () => {
const text = metaText()
@ -142,7 +143,6 @@ export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.record.role === "user"
const createdTimestamp = () => getMessageStartedAt(props.messageInfo, props.record.createdAt) ?? props.record.createdAt
const totalDuration = () => getMessageDurationMs(props.messageInfo, props.record.status, props.record.createdAt)
const totalDurationLabel = () => (!isUser() ? formatElapsedClock(totalDuration(), locale()) : "")
const timestamp = () => {
const date = new Date(createdTimestamp())
@ -158,6 +158,11 @@ export default function MessageItem(props: MessageItemProps) {
}
const messageParts = () => props.parts
const isAssistantTextBlock = () =>
!isUser() &&
messageParts().some(
(part) => part.type === "text" && !isHiddenSyntheticTextPart(part) && partHasRenderableText(part),
)
// 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
@ -354,9 +359,7 @@ export default function MessageItem(props: MessageItemProps) {
const containerClass = () =>
isUser()
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const speakerLabel = () => (isUser() ? t("messageItem.speaker.you") : t("messageItem.speaker.assistant"))
: "message-item-base assistant-message border-l-4 border-[var(--message-assistant-border)]"
const agentIdentifier = () => {
if (isUser()) return ""
@ -397,23 +400,66 @@ export default function MessageItem(props: MessageItemProps) {
return segments.join(" • ")
}
const actionMenuItems = (): ActionOverflowMenuItem[] => {
const items: ActionOverflowMenuItem[] = [
{
const speakerLabel = () => {
if (isUser()) return t("messageItem.speaker.you")
return agentMeta() || t("messageItem.speaker.assistant")
}
const workedDurationTooltip = () => {
if (isUser()) return ""
const duration = totalDuration()
if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0) return ""
const formatWorkedFor = (count: number, unit: "seconds" | "minutes" | "hours") =>
t(`messageItem.duration.workedFor.${unit}.${count === 1 ? "one" : "other"}`, { count })
const seconds = Math.max(1, Math.round(duration / 1000))
if (seconds < 60) {
return formatWorkedFor(seconds, "seconds")
}
const minutes = Math.max(1, Math.round(seconds / 60))
if (minutes < 60) {
return formatWorkedFor(minutes, "minutes")
}
const hours = Math.max(1, Math.round(minutes / 60))
return formatWorkedFor(hours, "hours")
}
const actionMenuItems = (includePrimaryActions = false): ActionOverflowMenuItem[] => {
const items: ActionOverflowMenuItem[] = []
if (props.showDeleteMessage) {
items.push({
key: "select",
label: isSelectedForDeletion()
? t("messageItem.selection.deselectForDeletion")
: t("messageItem.selection.selectForDeletion"),
icon: isSelectedForDeletion()
? <CheckSquare2 class="w-3.5 h-3.5" aria-hidden="true" />
: <Square class="w-3.5 h-3.5" aria-hidden="true" />,
checked: isSelectedForDeletion(),
onSelect: () => props.onToggleSelectedMessage?.(props.record.id, !isSelectedForDeletion()),
})
}
if (includePrimaryActions) {
items.push({
key: "copy",
label: copyLabel(),
icon: <Copy class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: handleCopy,
},
]
if (canSpeakMessage()) {
items.push({
key: "speak",
label: speech.buttonTitle(),
icon: <Volume2 class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => void speech.toggle(),
})
if (canSpeakMessage()) {
items.push({
key: "speak",
label: speech.buttonTitle(),
icon: <Volume2 class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => void speech.toggle(),
})
}
}
if (isUser() && props.onFork) {
@ -472,35 +518,17 @@ export default function MessageItem(props: MessageItemProps) {
data-message-id={props.record.id}
data-message-role={isUser() ? "user" : "assistant"}
data-message-status={props.record.status}
data-message-content-start-part-id={props.contentStartPartId}
data-assistant-text-block={isAssistantTextBlock() ? "true" : undefined}
>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<header class="message-item-header pb-0">
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
<div class="message-header-left">
<div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}>
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.record.id, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"} title={workedDurationTooltip() || undefined}>
{speakerLabel()}
</span>
<Show when={totalDurationLabel()}>
{(value) => <span class="message-duration">{value()}</span>}
</Show>
<time class="message-timestamp message-timestamp-inline" dateTime={timestampIso()}>{timestamp()}</time>
</div>
<Show when={metaText() && showMetaInline()}>
@ -519,7 +547,7 @@ export default function MessageItem(props: MessageItemProps) {
<div
class="message-item-actions"
data-action-overflow={actionMenuItems().length > 1 ? "true" : undefined}
data-action-overflow={actionMenuItems(true).length > 0 ? "true" : undefined}
ref={(el) => (actionsEl = el)}
>
<Show when={isUser()}>
@ -543,59 +571,18 @@ export default function MessageItem(props: MessageItemProps) {
/>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title={t("messageItem.actions.fork")}
aria-label={t("messageItem.actions.fork")}
>
<Split class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={handleRevert}
title={t("messageItem.actions.revertTitle")}
aria-label={t("messageItem.actions.revertTitle")}
>
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"
onClick={() => void handleDeleteUpTo()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
<ActionOverflowMenu
items={actionMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="message-action-button"
minItems={2}
triggerClass="message-action-button action-overflow-wide"
minItems={1}
/>
<ActionOverflowMenu
items={actionMenuItems(true)}
label={t("messageItem.actions.more")}
triggerClass="message-action-button action-overflow-narrow"
minItems={1}
/>
</Show>
<Show when={!isUser()}>
@ -619,42 +606,20 @@ export default function MessageItem(props: MessageItemProps) {
/>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"
onClick={() => void handleDeleteUpTo()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
<ActionOverflowMenu
items={actionMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="message-action-button"
minItems={2}
triggerClass="message-action-button action-overflow-wide"
minItems={1}
/>
<ActionOverflowMenu
items={actionMenuItems(true)}
label={t("messageItem.actions.more")}
triggerClass="message-action-button action-overflow-narrow"
minItems={1}
/>
</Show>
<div class="message-meta-timing">
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
</div>
</div>
@ -666,7 +631,7 @@ export default function MessageItem(props: MessageItemProps) {
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
<div class="pt-0 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
<Show when={props.isQueued && isUser()}>

View file

@ -36,10 +36,17 @@ export default function MessagePart(props: MessagePartProps) {
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const markdownContainerClass = () => "message-text message-text-assistant"
const textContainerRole = () => props.messageType || "assistant"
const isPrimaryUserTextPart = () =>
props.messageType === "user" &&
props.part?.type === "text" &&
typeof props.primaryUserTextPartId === "string" &&
props.primaryUserTextPartId.length > 0 &&
props.part.id === props.primaryUserTextPartId
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
if (isPrimaryUserTextPart()) return false
return Boolean((part as any).synthetic)
}
@ -213,7 +220,7 @@ export default function MessagePart(props: MessagePartProps) {
return (
<Switch>
<Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<Show when={!shouldHideTextPart() && (partHasRenderableText(props.part) || isPrimaryUserTextPart())}>
<div
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
dir="auto"
@ -270,6 +277,7 @@ export default function MessagePart(props: MessagePartProps) {
toolCallId={props.part?.id}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onRendered}
/>
</Suspense>
</Match>

View file

@ -1,11 +1,12 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
import { ChevronDown, ChevronUp, MoreHorizontal, Pause, Search, Trash, X } from "lucide-solid"
import { ArrowUpDown, ChevronDown, ChevronUp, MoreHorizontal, Pause, Search, Trash, X } from "lucide-solid"
import Kbd from "./kbd"
import BrandedEmptyState from "./branded-empty-state"
import MessageBlock from "./message-block"
import { getMessageAnchorId } from "./message-anchors"
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
import VirtualFollowList, { type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
import VirtualFollowList, { type VirtualFollowBottomIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
import { isSnapshotAutoFollowing } from "./virtual-follow-behavior"
import { useConfig } from "../stores/preferences"
import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
@ -21,6 +22,7 @@ import { buildRecordDisplayData } from "../stores/message-v2/record-display-cach
import { getPartCharCount } from "../lib/token-utils"
import { buildSessionSearchMatches } from "../lib/session-search"
import type { SessionSearchMatch } from "../lib/session-search"
import { resolveThinkingExpansionDefault } from "./tool-call/tool-registry"
const SCROLL_SENTINEL_MARGIN_PX = 8
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
@ -47,6 +49,7 @@ export interface MessageSectionProps {
onReloadMessages?: () => void
isActive?: boolean
sessionStreamingActive?: boolean
bottomFollowIntent?: VirtualFollowBottomIntent | null
}
export default function MessageSection(props: MessageSectionProps) {
@ -115,7 +118,7 @@ export default function MessageSection(props: MessageSectionProps) {
const preferenceSignature = createMemo(() => {
const pref = preferences()
const showThinking = pref.showThinkingBlocks ? 1 : 0
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
const thinkingExpansion = resolveThinkingExpansionDefault(pref) ? "expanded" : "collapsed"
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
return `${showThinking}|${thinkingExpansion}|${showUsage}`
})
@ -646,10 +649,13 @@ export default function MessageSection(props: MessageSectionProps) {
const isActive = createMemo(() => props.isActive !== false)
const [listApi, setListApi] = createSignal<VirtualFollowListApi | null>(null)
const [listState, setListState] = createSignal<VirtualFollowListState | null>(null)
const [scrollControlsOpen, setScrollControlsOpen] = createSignal(false)
const [scrollControlsHoverSuppressed, setScrollControlsHoverSuppressed] = createSignal(false)
const scrollButtonsCount = createMemo(() => listState()?.scrollButtonsCount() ?? 0)
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
let scrollControlsRef: HTMLDivElement | undefined
// Only preferences should force a follow-token re-anchor. Message/session
// revision churn at the end of a turn (message.updated, session.idle, etc.)
@ -657,7 +663,7 @@ export default function MessageSection(props: MessageSectionProps) {
const followToken = createMemo(() => preferenceSignature())
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
const initialAutoScroll = createMemo(() => isSnapshotAutoFollowing(initialScrollSnapshot()))
const [didRestoreScroll, setDidRestoreScroll] = createSignal(false)
const lastGoodScrollSnapshots = new Map<string, VirtualFollowScrollSnapshot>()
@ -773,6 +779,40 @@ export default function MessageSection(props: MessageSectionProps) {
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
}
function closeScrollControls() {
setScrollControlsOpen(false)
}
function openScrollControlsFromTrigger(event: PointerEvent) {
event.preventDefault()
event.stopPropagation()
if (scrollControlsOpen()) return
setScrollControlsHoverSuppressed(false)
setScrollControlsOpen(true)
}
function runScrollControlAction(event: PointerEvent, action: () => void) {
event.preventDefault()
event.stopPropagation()
action()
setScrollControlsHoverSuppressed(event.pointerType !== "mouse")
closeScrollControls()
}
createEffect(() => {
if (!scrollControlsOpen()) return
if (typeof document === "undefined") return
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null
if (target && scrollControlsRef?.contains(target)) return
closeScrollControls()
}
document.addEventListener("pointerdown", handlePointerDown)
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown))
})
function isStreamingAssistantTextMessage(messageId: string | null | undefined) {
if (!messageId) return false
const resolvedStore = store()
@ -829,8 +869,6 @@ export default function MessageSection(props: MessageSectionProps) {
setDidRestoreScroll(true)
},
onApplied: () => {
// Keep follow mode consistent with the restored state.
api.setAutoScroll(snapshot.atBottom)
restoringScrollSnapshot = false
setLastGoodScrollSnapshot(props.sessionId, snapshot)
setDidRestoreScroll(true)
@ -1338,10 +1376,12 @@ export default function MessageSection(props: MessageSectionProps) {
initialAutoScroll={initialAutoScroll}
resetKey={() => props.sessionId}
followToken={followToken}
forceBottomFollowIntent={() => props.bottomFollowIntent ?? null}
autoPinHoldEnabled={holdLongAssistantRepliesEnabled}
autoPinHoldTargetKey={autoPinHoldTargetKey}
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
resolveAutoPinHoldElement={(itemWrapper, key) => {
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"][data-assistant-text-block="true"]`))
return candidates[candidates.length - 1] ?? null
}}
onScroll={() => {
@ -1375,50 +1415,72 @@ export default function MessageSection(props: MessageSectionProps) {
registerApi={(api) => setListApi(api)}
registerState={(state) => setListState(state)}
renderControls={(state, api) => (
<div class="message-scroll-button-wrapper">
<div
ref={(el) => {
scrollControlsRef = el
}}
class="message-scroll-controls"
data-open={scrollControlsOpen() ? "true" : "false"}
data-hover-suppressed={scrollControlsHoverSuppressed() ? "true" : "false"}
onPointerLeave={(event) => {
if (event.pointerType === "mouse") setScrollControlsHoverSuppressed(false)
}}
>
<button
type="button"
class="message-scroll-button"
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
onClick={toggleHoldLongAssistantReplies}
aria-pressed={holdLongAssistantRepliesEnabled()}
aria-label={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
title={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
class="message-scroll-button message-scroll-controls-trigger"
onPointerUp={openScrollControlsFromTrigger}
aria-label={t("messageSection.scroll.showControlsAriaLabel")}
title={t("messageSection.scroll.showControlsAriaLabel")}
>
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
<ArrowUpDown class="message-scroll-icon w-4 h-4" aria-hidden="true" />
</button>
<Show when={state.showScrollTopButton()}>
<div class="message-scroll-controls-expanded">
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToTop()}
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
onPointerUp={(event) => runScrollControlAction(event, toggleHoldLongAssistantReplies)}
aria-pressed={holdLongAssistantRepliesEnabled()}
aria-label={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
title={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
</button>
</Show>
<Show when={state.showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToBottom({ suppressHold: true })}
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={state.showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToTop())}
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={state.showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom({ suppressHold: true }))}
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
</div>
)}
renderBeforeItems={() => (
@ -1493,7 +1555,7 @@ export default function MessageSection(props: MessageSectionProps) {
messageIndex={index}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
thinkingDefaultExpanded={() => resolveThinkingExpansionDefault(preferences())}
showUsageMetrics={showUsagePreference}
deleteHover={deleteHover}
onDeleteHoverChange={setDeleteHover}

View file

@ -138,8 +138,18 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
const [rejectingPermissionId, setRejectingPermissionId] = createSignal<string | null>(null)
const [rejectReason, setRejectReason] = createSignal("")
const [permissionRejectReasons, setPermissionRejectReasons] = createSignal<Map<string, string>>(new Map())
const getPermissionRejectReason = (permissionId: string) => permissionRejectReasons().get(permissionId) ?? ""
const setPermissionRejectReason = (permissionId: string, value: string) => {
setPermissionRejectReasons((prev) => {
const next = new Map(prev)
if (value.length === 0) next.delete(permissionId)
else next.set(permissionId, value)
return next
})
}
const setPermissionBusy = (permissionId: string, busy: boolean) => {
setPermissionSubmitting((prev) => {
@ -172,10 +182,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
const sessionId = getPermissionSessionId(permission)
if (!sessionId) throw new Error("Permission request is missing sessionID")
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response, message)
if (rejectingPermissionId() === permissionId) {
setRejectingPermissionId(null)
setRejectReason("")
}
setPermissionRejectReason(permissionId, "")
} catch (error) {
setPermissionItemError(
permissionId,
@ -379,86 +386,53 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<code>{primaryTitle()}</code>
</div>
<Show when={item.kind === "permission"}>
<Show
when={rejectingPermissionId() === item.id}
fallback={
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "once")}
>
{t("permissionApproval.actions.allowOnce")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "always")}
>
{t("permissionApproval.actions.alwaysAllow")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() => {
setRejectingPermissionId(item.id)
setRejectReason("")
}}
>
{t("permissionApproval.actions.deny")}
</button>
</div>
</div>
}
>
<div class="tool-call-permission-reject-reason">
<label class="tool-call-permission-reject-label" for={`permission-center-reject-reason-${item.id}`}>
{t("permissionApproval.rejectReason.label")}
</label>
<textarea
id={`permission-center-reject-reason-${item.id}`}
class="tool-call-permission-reject-textarea"
value={rejectReason()}
rows={3}
maxLength={PERMISSION_REJECT_REASON_MAX_LENGTH}
placeholder={t("permissionApproval.rejectReason.placeholder")}
<div class="tool-call-permission-reject-reason">
<textarea
id={`permission-center-reject-reason-${item.id}`}
class="tool-call-permission-reject-textarea"
value={getPermissionRejectReason(item.id)}
rows={1}
maxLength={PERMISSION_REJECT_REASON_MAX_LENGTH}
placeholder={t("permissionApproval.rejectReason.placeholder")}
aria-label={t("permissionApproval.rejectReason.placeholder")}
disabled={permissionSubmitting().has(item.id)}
onInput={(event) => setPermissionRejectReason(item.id, event.currentTarget.value)}
/>
</div>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onInput={(event) => setRejectReason(event.currentTarget.value)}
/>
<p class="tool-call-permission-reject-hint">{t("permissionApproval.rejectReason.hint")}</p>
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() =>
void handlePermissionDecision(
item.payload as PermissionRequest,
"reject",
rejectReason().trim() || undefined,
)
}
>
{t("permissionApproval.actions.confirmDeny")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() => {
setRejectingPermissionId(null)
setRejectReason("")
}}
>
{t("permissionApproval.actions.cancel")}
</button>
</div>
onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "once")}
>
{t("permissionApproval.actions.allowOnce")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "always")}
>
{t("permissionApproval.actions.alwaysAllow")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting().has(item.id)}
onClick={() =>
void handlePermissionDecision(
item.payload as PermissionRequest,
"reject",
getPermissionRejectReason(item.id).trim() || undefined,
)
}
>
{t("permissionApproval.actions.deny")}
</button>
</div>
</Show>
</div>
<Show when={permissionError().get(item.id)}>
{(err) => <div class="tool-call-permission-error">{err()}</div>}
</Show>

View file

@ -1,4 +1,4 @@
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Paperclip, Volume2, X } from "lucide-solid"
import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments"
@ -33,6 +33,13 @@ const log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
const DEFAULT_PROMPT_FIELD_HEIGHT = 104
const MAX_PROMPT_FIELD_HEIGHT_RATIO = 0.6
type SessionCenterWidthStep = "narrow" | "medium" | "wide"
function getSessionCenterWidthStep(width: number): SessionCenterWidthStep {
if (width < 768) return "narrow"
if (width < 1280) return "medium"
return "wide"
}
type ResizeDragState = {
pointerId: number
@ -75,7 +82,9 @@ export default function PromptInput(props: PromptInputProps) {
const [mode, setMode] = createSignal<PromptMode>("normal")
const [expandState, setExpandState] = createSignal<ExpandState>("normal")
const [inputHeight, setInputHeight] = createSignal<number | null>(null)
const [autoInputHeight, setAutoInputHeight] = createSignal<number | null>(null)
const [isResizing, setIsResizing] = createSignal(false)
const [sessionCenterWidthStep, setSessionCenterWidthStep] = createSignal<SessionCenterWidthStep | null>(null)
const [isFileBrowserOpen, setIsFileBrowserOpen] = createSignal(false)
const SELECTION_INSERT_MAX_LENGTH = 2000
const MAX_READABLE_PICKED_FILE_BYTES = 5 * 1024 * 1024
@ -92,6 +101,54 @@ export default function PromptInput(props: PromptInputProps) {
return t("promptInput.placeholder.default")
}
const compactAutosizeEnabled = () => {
const widthStep = sessionCenterWidthStep()
return props.compactLayout && expandState() === "normal" && inputHeight() === null && widthStep === "narrow"
}
const effectiveInputHeight = () => inputHeight() ?? autoInputHeight()
const fieldHeightStyle = () => {
const height = effectiveInputHeight()
if (height === null) return undefined
if (inputHeight() !== null) return { height: `${height}px`, "min-height": `${height}px` }
return { height: `${height}px` }
}
const textareaHeightStyle = () => {
const height = effectiveInputHeight()
if (height === null) return undefined
const overflowY: "auto" | "hidden" = inputHeight() !== null || height >= DEFAULT_PROMPT_FIELD_HEIGHT ? "auto" : "hidden"
if (inputHeight() !== null) {
return {
height: `${height}px`,
"min-height": `${height}px`,
"overflow-y": overflowY,
}
}
return { height: `${height}px`, "overflow-y": overflowY }
}
const textareaRows = () => {
if (expandState() === "expanded") return props.compactLayout ? 10 : 15
return compactAutosizeEnabled() ? 2 : 3
}
const syncCompactAutoHeight = () => {
const textarea = textareaRef
if (!textarea || !compactAutosizeEnabled()) {
setAutoInputHeight(null)
return
}
const previousHeight = textarea.style.height
textarea.style.height = "auto"
const measuredHeight = textarea.scrollHeight
textarea.style.height = previousHeight
const nextHeight = Math.min(DEFAULT_PROMPT_FIELD_HEIGHT, measuredHeight)
setAutoInputHeight(nextHeight)
}
const promptState = usePromptState({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
@ -112,6 +169,31 @@ export default function PromptInput(props: PromptInputProps) {
selectNextHistory,
} = promptState
onMount(() => {
const sessionCenter = wrapperRef?.closest("[data-session-center-width]") as HTMLElement | null
if (!sessionCenter) return
const syncWidthStep = () => {
setSessionCenterWidthStep(getSessionCenterWidthStep(sessionCenter.getBoundingClientRect().width))
}
syncWidthStep()
if (typeof ResizeObserver === "undefined") return
const observer = new ResizeObserver(syncWidthStep)
observer.observe(sessionCenter)
onCleanup(() => observer.disconnect())
})
createEffect(() => {
prompt()
expandState()
inputHeight()
props.compactLayout
sessionCenterWidthStep()
queueMicrotask(syncCompactAutoHeight)
})
const {
attachments,
isDragging,
@ -248,6 +330,11 @@ export default function PromptInput(props: PromptInputProps) {
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches)
}
const isTouchOnlyPointer = () => {
if (typeof window === "undefined") return false
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches && !window.matchMedia?.("(any-pointer: fine)")?.matches)
}
createEffect(() => {
// Scope global "type-to-focus" behavior to the active, visible prompt only.
if (typeof document === "undefined") return
@ -450,7 +537,9 @@ export default function PromptInput(props: PromptInputProps) {
variant: "error",
})
} finally {
textareaRef?.focus()
if (!isTouchOnlyPointer()) {
textareaRef?.focus()
}
}
}
@ -703,6 +792,7 @@ export default function PromptInput(props: PromptInputProps) {
<div
ref={wrapperRef}
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
data-compact-layout={props.compactLayout ? "true" : undefined}
style={
isDragging()
? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);"
@ -736,8 +826,8 @@ export default function PromptInput(props: PromptInputProps) {
<div class="prompt-input-main flex flex-1 flex-col">
<div
ref={fieldContainerRef}
class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""} ${inputHeight() !== null ? "is-resized" : ""}`}
style={inputHeight() !== null ? { height: `${inputHeight()}px`, "min-height": `${inputHeight()}px` } : undefined}
class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""} ${effectiveInputHeight() !== null ? "is-resized" : ""}`}
style={fieldHeightStyle()}
>
<div
class={`prompt-resize-handle ${isResizing() ? "is-resizing" : ""}`}
@ -752,7 +842,7 @@ export default function PromptInput(props: PromptInputProps) {
<div
class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}
style={inputHeight() !== null ? { height: `${inputHeight()}px`, "min-height": `${inputHeight()}px` } : undefined}
style={fieldHeightStyle()}
>
<textarea
ref={textareaRef}
@ -766,12 +856,12 @@ export default function PromptInput(props: PromptInputProps) {
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
rows={textareaRows()}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
style={inputHeight() !== null ? { height: `${inputHeight()}px`, "min-height": `${inputHeight()}px`, "overflow-y": "auto" } : undefined}
style={textareaHeightStyle()}
/>
<div class="prompt-expand-button-inline">
<ExpandButton

View file

@ -67,7 +67,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
}
async function handleCancel() {
await stopInstance(props.instanceId)
stopInstance(props.instanceId)
props.onClose()
}

View file

@ -0,0 +1,56 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { resolveSessionBottomFollowIntent, shouldClearSessionBottomFollowIntent } from "./session-bottom-follow-intent.ts"
describe("session bottom follow intent", () => {
it("only exposes submit follow intent to the matching session", () => {
const intent = { sessionId: "session-a", token: 3, minItemCount: 12 }
assert.deepEqual(resolveSessionBottomFollowIntent(intent, "session-a"), {
token: 3,
minItemCount: 12,
})
assert.equal(resolveSessionBottomFollowIntent(intent, "session-b"), null)
})
it("clears submit follow intent only after the submitted exchange has rendered and stopped streaming", () => {
const intent = { sessionId: "session-a", token: 3, minItemCount: 12 }
assert.equal(
shouldClearSessionBottomFollowIntent(intent, {
sessionId: "session-a",
messageCount: 11,
streamingActive: false,
}),
false,
)
assert.equal(
shouldClearSessionBottomFollowIntent(intent, {
sessionId: "session-a",
messageCount: 12,
streamingActive: true,
}),
false,
)
assert.equal(
shouldClearSessionBottomFollowIntent(intent, {
sessionId: "session-a",
messageCount: 12,
streamingActive: false,
}),
true,
)
assert.equal(
shouldClearSessionBottomFollowIntent(intent, {
sessionId: "session-b",
messageCount: 12,
streamingActive: false,
}),
false,
)
})
})

View file

@ -0,0 +1,22 @@
import type { VirtualFollowBottomIntent } from "../virtual-follow-list"
export interface SessionBottomFollowIntent extends VirtualFollowBottomIntent {
sessionId: string
}
export function resolveSessionBottomFollowIntent(
intent: SessionBottomFollowIntent | null,
sessionId: string,
): VirtualFollowBottomIntent | null {
if (!intent || intent.sessionId !== sessionId) return null
return { token: intent.token, minItemCount: intent.minItemCount }
}
export function shouldClearSessionBottomFollowIntent(
intent: SessionBottomFollowIntent | null,
state: { sessionId: string; messageCount: number; streamingActive: boolean },
) {
if (!intent) return false
if (intent.sessionId !== state.sessionId) return false
return state.messageCount >= (intent.minItemCount ?? 0) && !state.streamingActive
}

View file

@ -20,6 +20,8 @@ import { clearConversationPlaybackForSession } from "../../stores/conversation-s
import { useConfig } from "../../stores/preferences"
import { closeSessionPreview, getSessionPreview, showSessionChat } from "../../stores/session-previews"
import { SessionPreviewView } from "../session-preview-view"
import { isSnapshotAutoFollowing } from "../virtual-follow-behavior"
import { resolveSessionBottomFollowIntent, shouldClearSessionBottomFollowIntent, type SessionBottomFollowIntent } from "./session-bottom-follow-intent"
const log = getLogger("session")
@ -79,19 +81,23 @@ export const SessionView: Component<SessionViewProps> = (props) => {
let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined
const pendingIdleSeenTimers = new Set<string>()
const [pendingSubmitBottomScrollTargetCount, setPendingSubmitBottomScrollTargetCount] = createSignal<number | null>(null)
const [submitBottomFollowIntent, setSubmitBottomFollowIntent] = createSignal<SessionBottomFollowIntent | null>(null)
let submitBottomFollowIntentSequence = 0
function shouldScrollToBottomOnActivate() {
const current = session()
if (!current) return true
const snapshot = messageStore().getScrollSnapshot(current.id, MESSAGE_SCROLL_CACHE_SCOPE)
return !snapshot || snapshot.atBottom
return isSnapshotAutoFollowing(snapshot)
}
function scheduleScrollToBottom(options?: { force?: boolean }) {
function scheduleScrollToBottom(options?: { force?: boolean; sessionId?: string }) {
if (!scrollToBottomHandle) return false
const targetSessionId = options?.sessionId ?? props.sessionId
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const current = session()
if (!current || current.id !== targetSessionId) return
if (!options?.force && !shouldScrollToBottomOnActivate()) return
scrollToBottomHandle?.()
})
@ -99,6 +105,33 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return true
}
function startSubmitBottomFollowIntent(minItemCount: number) {
submitBottomFollowIntentSequence += 1
setSubmitBottomFollowIntent({ sessionId: props.sessionId, token: submitBottomFollowIntentSequence, minItemCount })
}
function forceSubmittedExchangeToBottom(minItemCount: number) {
startSubmitBottomFollowIntent(minItemCount)
scrollToBottomHandle?.()
}
const activeSubmitBottomFollowIntent = createMemo(() => {
const intent = submitBottomFollowIntent()
const currentSession = session()
if (!intent || !currentSession) return null
const messageCount = messageStore().getSessionMessageIds(currentSession.id).length
if (shouldClearSessionBottomFollowIntent(intent, {
sessionId: currentSession.id,
messageCount,
streamingActive: sessionStreamingActive(),
})) {
return null
}
return resolveSessionBottomFollowIntent(intent, currentSession.id)
})
function getSeenIdleEntries(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): Array<{ id: string; idleSince: number }> {
const entries: Array<{ id: string; idleSince: number }> = []
@ -130,6 +163,21 @@ export const SessionView: Component<SessionViewProps> = (props) => {
),
)
createEffect(() => {
const intent = submitBottomFollowIntent()
const currentSession = session()
if (!intent || !currentSession) return
const messageCount = messageStore().getSessionMessageIds(currentSession.id).length
if (shouldClearSessionBottomFollowIntent(intent, {
sessionId: currentSession.id,
messageCount,
streamingActive: sessionStreamingActive(),
})) {
setSubmitBottomFollowIntent(null)
}
})
createEffect(() => {
const currentSession = session()
if (!props.isActive || !currentSession) return
@ -216,21 +264,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
)
}
createEffect(
on(
() => messageStore().getSessionMessageIds(props.sessionId).length,
(messageCount) => {
const targetCount = pendingSubmitBottomScrollTargetCount()
if (targetCount === null) return
const didSchedule = scheduleScrollToBottom({ force: true })
if (didSchedule && messageCount >= targetCount) {
setPendingSubmitBottomScrollTargetCount(null)
}
},
{ defer: true },
),
)
function registerPromptInputApi(api: PromptInputApi) {
promptInputApi = api
props.registerSessionPromptApi?.(props.sessionId, api)
@ -276,14 +309,14 @@ export const SessionView: Component<SessionViewProps> = (props) => {
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
const messageCount = messageStore().getSessionMessageIds(props.sessionId).length
setPendingSubmitBottomScrollTargetCount(messageCount + 2)
scheduleScrollToBottom({ force: true })
const submittedExchangeTargetCount = messageCount + 2
forceSubmittedExchangeToBottom(submittedExchangeTargetCount)
try {
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
scheduleScrollToBottom({ force: true })
setPendingSubmitBottomScrollTargetCount(null)
const latestMessageCount = messageStore().getSessionMessageIds(props.sessionId).length
forceSubmittedExchangeToBottom(Math.max(submittedExchangeTargetCount, latestMessageCount))
} catch (error) {
setPendingSubmitBottomScrollTargetCount(null)
setSubmitBottomFollowIntent(null)
throw error
}
}
@ -448,20 +481,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
loadError={messagesLoadError()}
onReloadMessages={handleReloadMessages}
sessionStreamingActive={sessionStreamingActive()}
bottomFollowIntent={activeSubmitBottomFollowIntent()}
onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
onFork={handleFork}
isActive={props.isActive}
registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn ?? undefined
if (!fn) return
const targetCount = pendingSubmitBottomScrollTargetCount()
if (targetCount === null) return
const didSchedule = scheduleScrollToBottom({ force: true })
const messageCount = messageStore().getSessionMessageIds(props.sessionId).length
if (didSchedule && messageCount >= targetCount) {
setPendingSubmitBottomScrollTargetCount(null)
}
}}
showSidebarToggle={props.showSidebarToggle}
onSidebarToggle={props.onSidebarToggle}

View file

@ -3,8 +3,14 @@ import { createEffect, createMemo, createSignal, For, type Component } from "sol
import { Check, ChevronDown, Laptop, Moon, Sun } from "lucide-solid"
import { useI18n } from "../../lib/i18n"
import { useTheme, type ThemeMode } from "../../lib/theme"
import { useConfig } from "../../stores/preferences"
import { useConfig, type ExpansionPreference, type ToolCallExpansionPreset } from "../../stores/preferences"
import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry"
import {
buildToolExpansionPresetDefaults,
getConfigurableToolEntries,
OTHER_TOOL_NAME,
THINKING_EXPANSION_PRESETS,
} from "../tool-call/tool-registry"
const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [
{ value: "system", icon: Laptop },
@ -12,6 +18,8 @@ const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [
{ value: "dark", icon: Moon },
]
const toolExpansionPresetOptions: ToolCallExpansionPreset[] = ["minimal", "balanced", "detailed", "everything"]
export const AppearanceSettingsSection: Component = () => {
const { t } = useI18n()
const { themeMode, setThemeMode } = useTheme()
@ -45,16 +53,18 @@ export const AppearanceSettingsSection: Component = () => {
toggleKeyboardShortcutHints,
toggleShowMessageTimeline,
toggleShowTimelineTools,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
}),
}).filter(
(setting) => setting.id !== "behavior.thinkingBlocksDefault" && setting.id !== "behavior.toolOutputsDefault",
),
)
const [overrides, setOverrides] = createSignal<Map<string, unknown>>(new Map())
@ -99,6 +109,78 @@ export const AppearanceSettingsSection: Component = () => {
type SelectOption = { value: string; label: string }
type ExpansionRow =
| { kind: "thinking"; key: "thinking"; label: string }
| { kind: "tool"; key: string; label: string }
const expansionOptions = createMemo<SelectOption[]>(() => [
{ value: "collapsed", label: t("commands.common.collapsed") },
{ value: "expanded", label: t("commands.common.expanded") },
])
const toolExpansionRows = createMemo<ExpansionRow[]>(() => [
{ kind: "thinking", key: "thinking", label: t("settings.behavior.expansionDefaults.thinking") },
...getConfigurableToolEntries().map((entry) => ({
kind: "tool" as const,
key: entry.tool,
label: entry.labelKey ? t(entry.labelKey) : entry.label,
})),
])
const currentPreset = createMemo(() => preferences().toolCallExpansionDefaults.preset)
const currentToolMode = (tool: string): ExpansionPreference => {
const pref = preferences().toolCallExpansionDefaults
const entry = getConfigurableToolEntries().find((item) => item.tool === tool)
if (pref.tools[tool]) return pref.tools[tool]
if (pref.preset !== "custom" && entry) return entry.expansionPresets[pref.preset]
return pref.tools[OTHER_TOOL_NAME] ?? "expanded"
}
const currentThinkingMode = (): ExpansionPreference => {
const pref = preferences().toolCallExpansionDefaults
if (pref.thinking) return pref.thinking
if (pref.preset !== "custom") return THINKING_EXPANSION_PRESETS[pref.preset]
return preferences().thinkingBlocksExpansion ?? "expanded"
}
const materializeToolModes = () => {
const tools: Record<string, ExpansionPreference> = {}
for (const entry of getConfigurableToolEntries()) {
tools[entry.tool] = currentToolMode(entry.tool)
}
return tools
}
const applyExpansionPreset = (preset: ToolCallExpansionPreset) => {
const tools = buildToolExpansionPresetDefaults(preset)
const thinking = THINKING_EXPANSION_PRESETS[preset]
updatePreferences({
toolCallExpansionDefaults: { preset, thinking, tools },
thinkingBlocksExpansion: thinking,
toolOutputExpansion: tools[OTHER_TOOL_NAME] ?? "expanded",
})
}
const setExpansionRowMode = (row: ExpansionRow, mode: ExpansionPreference) => {
const tools = materializeToolModes()
const thinking = row.kind === "thinking" ? mode : currentThinkingMode()
if (row.kind === "tool") {
tools[row.key] = mode
}
updatePreferences({
toolCallExpansionDefaults: { preset: "custom", thinking, tools },
thinkingBlocksExpansion: thinking,
toolOutputExpansion: tools[OTHER_TOOL_NAME] ?? preferences().toolOutputExpansion,
})
}
const rowMode = (row: ExpansionRow): ExpansionPreference =>
row.kind === "thinking" ? currentThinkingMode() : currentToolMode(row.key)
const selectedExpansionOption = (mode: ExpansionPreference) =>
expansionOptions().find((opt) => opt.value === mode)
const BehaviorRow: Component<{ setting: BehaviorSetting }> = (props) => {
const setting = props.setting
const disabled = createMemo(() => (setting.disabled ? Boolean(setting.disabled()) : false))
@ -224,6 +306,8 @@ export const AppearanceSettingsSection: Component = () => {
return t("theme.mode.dark")
}
const presetLabel = (preset: ToolCallExpansionPreset | "custom") => t(`settings.behavior.expansionPreset.${preset}.title`)
return (
<div class="settings-section-stack">
<div class="settings-card">
@ -266,7 +350,80 @@ export const AppearanceSettingsSection: Component = () => {
<h3 class="settings-card-title">{t("settings.appearance.behavior.title")}</h3>
<p class="settings-card-subtitle">{t("settings.appearance.behavior.subtitle")}</p>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
<span class="settings-scope-badge">{presetLabel(currentPreset())}</span>
</div>
<div class="settings-expansion-presets" aria-label={t("settings.behavior.expansionPresets.ariaLabel")}>
<For each={toolExpansionPresetOptions}>
{(preset) => (
<button
type="button"
class="settings-expansion-preset"
data-selected={currentPreset() === preset ? "true" : "false"}
onClick={() => applyExpansionPreset(preset)}
>
<span class="settings-expansion-preset-title">{presetLabel(preset)}</span>
<span class="settings-expansion-preset-copy">{t(`settings.behavior.expansionPreset.${preset}.description`)}</span>
</button>
)}
</For>
</div>
<div class="settings-expansion-table" role="table" aria-label={t("settings.behavior.expansionDefaults.title")}>
<div class="settings-expansion-table-header" role="row">
<span role="columnheader">{t("settings.behavior.expansionDefaults.itemColumn")}</span>
<span role="columnheader">{t("settings.behavior.expansionDefaults.stateColumn")}</span>
</div>
<For each={toolExpansionRows()}>
{(row) => {
const selected = createMemo(() => selectedExpansionOption(rowMode(row)))
return (
<div class="settings-expansion-row" role="row">
<div class="settings-expansion-row-label" role="cell">
<code>{row.label}</code>
</div>
<div class="settings-expansion-row-control" role="cell">
<Select<SelectOption>
value={selected()}
onChange={(opt) => {
if (!opt) return
setExpansionRowMode(row, opt.value as ExpansionPreference)
}}
options={expansionOptions()}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger class="selector-trigger settings-expansion-select" aria-label={t("settings.behavior.expansionDefaults.rowAriaLabel", { item: row.label })}>
<div class="flex-1 min-w-0">
<Select.Value<SelectOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
</div>
)
}}
</For>
</div>
<div class="settings-stack">

View file

@ -1,5 +1,5 @@
import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, Volume2, XCircle } from "lucide-solid"
import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor, type JSXElement } from "solid-js"
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, Volume2, WrapText, XCircle } from "lucide-solid"
import { stringify as stringifyYaml } from "yaml"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useTheme } from "../lib/theme"
@ -12,6 +12,7 @@ import { getPermissionSessionId } from "../types/permission"
import type { QuestionRequest } from "../types/question"
import { useI18n } from "../lib/i18n"
import { resolveToolRenderer } from "./tool-call/renderers"
import { resolveToolExpansionDefault } from "./tool-call/tool-registry"
import { QuestionToolBlock } from "./tool-call/question-block"
import { PermissionToolBlock } from "./tool-call/permission-block"
import { createAnsiContentRenderer } from "./tool-call/ansi-render"
@ -25,6 +26,7 @@ import type {
MarkdownRenderOptions,
AnsiRenderOptions,
ToolCallPart,
ToolOutputChrome,
ToolRendererContext,
ToolScrollHelpers,
} from "./tool-call/types"
@ -32,7 +34,6 @@ import {
buildToolSpeechText,
ensureMarkdownContent,
getRelativePath,
getToolIcon,
getToolName,
isToolStateCompleted,
isToolStateError,
@ -40,12 +41,11 @@ import {
getDefaultToolAction,
readToolStatePayload,
} from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu"
import SpeechActionButton from "./speech-action-button"
const log = getLogger("session")
@ -80,6 +80,8 @@ interface ToolCallProps {
* Users can still expand/collapse manually.
*/
forceCollapsed?: boolean
headerAction?: JSXElement
headerMenuItems?: () => ActionOverflowMenuItem[]
}
function ToolStatusIndicator(props: { status: Accessor<string> }) {
@ -128,6 +130,8 @@ function ToolCallDetails(props: {
toolInput: () => Record<string, any> | undefined
inputSectionExpanded: () => boolean
outputSectionExpanded: () => boolean
outputWrapEnabled: Accessor<boolean>
toggleOutputWrap: () => void
toggleInputSection: () => void
toggleOutputSection: () => void
toolCallRootEl: () => HTMLDivElement | undefined
@ -186,7 +190,6 @@ function ToolCallDetails(props: {
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [permissionRejectReasonOpen, setPermissionRejectReasonOpen] = createSignal(false)
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
@ -247,7 +250,6 @@ function ToolCallDetails(props: {
if (!activeKey) return
const handler = (event: KeyboardEvent) => {
if (isTextInputFocused()) return
if (permissionRejectReasonOpen()) return
const permission = permissionDetails()
if (!permission || !props.isPermissionActive()) return
if (event.key === "Enter") {
@ -354,18 +356,20 @@ function ToolCallDetails(props: {
const status = () => props.toolState()?.status || ""
const toolInputMarkdown = createMemo(() => {
const toolInputDisplay = createMemo((): { content: string; copyText: string; language: string } | null => {
const input = props.toolInput()
if (!input || Object.keys(input).length === 0) return null
try {
const yamlText = stringifyYaml(input)
return ensureMarkdownContent(yamlText, "yaml", true)
const content = ensureMarkdownContent(yamlText, "yaml", true)
return content ? { content, copyText: yamlText, language: "yaml" } : null
} catch (error) {
log.error("Failed to convert tool call input to YAML", error)
try {
const jsonText = JSON.stringify(input, null, 2)
return ensureMarkdownContent(jsonText, "json", true)
const content = ensureMarkdownContent(jsonText, "json", true)
return content ? { content, copyText: jsonText, language: "json" } : null
} catch (nestedError) {
log.error("Failed to stringify tool call input", nestedError)
return null
@ -407,6 +411,9 @@ function ToolCallDetails(props: {
onContentRendered: props.onContentRendered,
})
const renderOutputMarkdownContent: ToolRendererContext["renderMarkdown"] = (options) =>
renderMarkdownContent({ ...options, wrap: options.wrap ?? props.outputWrapEnabled() })
const rendererContext: ToolRendererContext = {
toolCall: props.toolCallMemo,
toolState: props.toolState,
@ -416,7 +423,7 @@ function ToolCallDetails(props: {
t: props.t,
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown: renderMarkdownContent,
renderMarkdown: renderOutputMarkdownContent,
renderAnsi: renderAnsiContent,
renderDiff: renderDiffContent,
renderToolCall: (options) => {
@ -435,6 +442,7 @@ function ToolCallDetails(props: {
/>
)
},
outputWrapEnabled: props.outputWrapEnabled,
scrollHelpers,
onContentRendered: props.onContentRendered,
}
@ -462,6 +470,8 @@ function ToolCallDetails(props: {
return renderer().renderBody(rendererContext)
}
const outputChrome = createMemo<ToolOutputChrome>(() => renderer().getOutputChrome?.(rendererContext) ?? {})
const renderError = () => {
const state = props.toolState()
if (state?.status === "error" && state.error) {
@ -482,7 +492,6 @@ function ToolCallDetails(props: {
error={permissionError}
renderDiff={renderDiffContent}
fallbackSessionId={() => props.sessionId}
onRejectReasonOpenChange={setPermissionRejectReasonOpen}
onRespond={(permission, sessionId, response, message) => void handlePermissionResponse(permission, response, message)}
/>
)
@ -503,59 +512,190 @@ function ToolCallDetails(props: {
/>
)
return (
<div class="tool-call-details">
<Show
when={props.isToolInputVisible() && props.hasToolInput()}
fallback={
<>
{renderToolBody()}
{renderError()}
const shouldShowPendingMessage = () => {
const tool = props.toolName()
return status() === "pending" && !props.pendingPermission() && tool !== "todowrite"
}
<Show when={status() === "pending" && !props.pendingPermission()}>
const copyIoText = async (event: MouseEvent, text?: string | null) => {
event.preventDefault()
event.stopPropagation()
if (!text) return
await copyToClipboard(text)
}
const outputWrapTitle = () =>
props.outputWrapEnabled()
? props.t("toolCall.diff.disableWordWrap")
: props.t("toolCall.diff.enableWordWrap")
const renderIoHeader = (options: {
title: () => string
language?: () => string | null | undefined
expanded: () => boolean
onToggle: () => void
copyText?: () => string | null | undefined
copyTitle?: () => string
copyAriaLabel?: () => string
actions?: () => JSXElement
wrapToggle?: () => boolean | undefined
}) => (
<div class="tool-call-io-header">
<button type="button" class="tool-call-io-toggle" aria-expanded={options.expanded()} onClick={options.onToggle}>
<span class="tool-call-io-disclosure" aria-hidden="true">{options.expanded() ? "▼" : "▶"}</span>
<span class="tool-call-io-title">{options.title()}</span>
<Show when={options.language?.()}>
{(language) => <span class="tool-call-io-language">{language()}</span>}
</Show>
</button>
<Show when={options.actions?.()}>
{(actions) => <span class="tool-call-io-actions">{actions()}</span>}
</Show>
<Show when={options.copyText?.()}>
{(copyText) => (
<button
type="button"
class="tool-call-header-icon-button tool-call-header-copy tool-call-io-copy"
onClick={(event) => void copyIoText(event, copyText())}
aria-label={options.copyAriaLabel?.() ?? props.t("toolCall.io.copyOutputAriaLabel")}
title={options.copyTitle?.() ?? props.t("toolCall.io.copyOutputTitle")}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show>
<Show when={options.wrapToggle?.()}>
<button
type="button"
class={`tool-call-header-icon-button tool-call-header-copy tool-call-io-wrap${props.outputWrapEnabled() ? " active" : ""}`}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.toggleOutputWrap()
}}
aria-label={outputWrapTitle()}
title={outputWrapTitle()}
>
<WrapText class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
)
const shouldUseToolHeaderOutputControls = () => Boolean(outputChrome().wrapToggle)
const renderToolOutputBody = () => {
const body = renderToolBody()
const error = renderError()
const showPending = shouldShowPendingMessage()
const chrome = outputChrome()
if (!body && !error && !showPending) {
return null
}
if (chrome.wrapToggle) {
return (
<div class="tool-call-body">
<div class="tool-call-io-body" data-suppress-inner-header={chrome.suppressInnerHeader === false ? undefined : "true"}>
{body}
{error}
<Show when={showPending}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{props.t("toolCall.pending.waitingToRun")}</span>
</div>
</Show>
</>
}
>
<div class="tool-call-io-sections">
<div class="tool-call-io-section">
<button type="button" class="tool-call-io-toggle" aria-expanded={props.inputSectionExpanded()} onClick={props.toggleInputSection}>
<span class="tool-call-io-title">{props.t("toolCall.io.input")}</span>
</button>
<Show when={props.inputSectionExpanded()}>
<div class="tool-call-io-body">
{(() => {
const content = toolInputMarkdown()
if (!content) return null
return renderMarkdownContent({ content, cacheKey: "input" })
})()}
</div>
</Show>
</div>
</div>
)
}
<div class="tool-call-io-section">
<button type="button" class="tool-call-io-toggle" aria-expanded={props.outputSectionExpanded()} onClick={props.toggleOutputSection}>
<span class="tool-call-io-title">{props.t("toolCall.io.output")}</span>
</button>
return (
<div class="tool-call-body">
{body}
{error}
<Show when={props.outputSectionExpanded()}>
<div class="tool-call-io-body">
{renderToolBody()}
{renderError()}
<Show when={showPending}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{props.t("toolCall.pending.waitingToRun")}</span>
</div>
</Show>
</div>
)
}
<Show when={status() === "pending" && !props.pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{props.t("toolCall.pending.waitingToRun")}</span>
return (
<div class="tool-call-details">
<Show
when={props.isToolInputVisible() && props.hasToolInput()}
fallback={renderToolOutputBody()}
>
<div class="tool-call-body">
<div class="tool-call-io-sections">
<div class="tool-call-io-section">
{(() => {
return renderIoHeader({
title: () => props.t("toolCall.io.input"),
language: () => toolInputDisplay()?.language,
expanded: props.inputSectionExpanded,
onToggle: props.toggleInputSection,
copyText: () => toolInputDisplay()?.copyText,
copyTitle: () => props.t("toolCall.io.copyInputTitle"),
copyAriaLabel: () => props.t("toolCall.io.copyInputAriaLabel"),
})
})()}
<Show when={props.inputSectionExpanded()}>
<div class="tool-call-io-body" data-suppress-inner-header="true">
{(() => {
const input = toolInputDisplay()
if (!input) return null
return renderMarkdownContent({ content: input.content, cacheKey: "input" })
})()}
</div>
</Show>
</div>
<div class="tool-call-io-section">
<Show when={!shouldUseToolHeaderOutputControls()}>
{renderIoHeader({
title: () => outputChrome().title || props.t("toolCall.io.output"),
language: () => outputChrome().language,
expanded: props.outputSectionExpanded,
onToggle: props.toggleOutputSection,
copyText: () => outputChrome().copyText,
copyTitle: () => props.t("toolCall.io.copyOutputTitle"),
copyAriaLabel: () => props.t("toolCall.io.copyOutputAriaLabel"),
actions: () => outputChrome().actions,
wrapToggle: () => outputChrome().wrapToggle,
})}
</Show>
<Show when={shouldUseToolHeaderOutputControls() || props.outputSectionExpanded()}>
<div>
<div
class="tool-call-io-body"
data-suppress-inner-header={outputChrome().suppressInnerHeader === false ? undefined : "true"}
>
{renderToolBody()}
{renderError()}
<Show when={shouldShowPendingMessage()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>{props.t("toolCall.pending.waitingToRun")}</span>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</div>
</div>
</Show>
@ -606,26 +746,21 @@ export default function ToolCall(props: ToolCallProps) {
return undefined
})
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
const defaultExpandedForTool = createMemo(() => {
if (props.forceCollapsed) {
return false
}
const prefExpanded = toolOutputDefaultExpanded()
const toolName = toolCallMemo()?.tool || ""
if (toolName === "read") {
const state = toolState()
if (state?.status === "error") {
return true
}
return false
const state = toolState()
if (state?.status === "error") {
return true
}
return prefExpanded
return resolveToolExpansionDefault(preferences(), toolCallMemo()?.tool || "")
})
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
const [outputWrapEnabled, setOutputWrapEnabled] = createSignal(true)
const toolInputsVisibility = createMemo(() => preferences().toolInputsVisibility || "collapsed")
const [toolInputVisibilityOverride, setToolInputVisibilityOverride] = createSignal<"hidden" | "expanded" | null>(null)
const effectiveToolInputsVisibility = createMemo(() => toolInputVisibilityOverride() ?? toolInputsVisibility())
@ -706,6 +841,9 @@ export default function ToolCall(props: ToolCallProps) {
return !current
})
}
const toggleOutputWrap = () => setOutputWrapEnabled((enabled) => !enabled)
const statusClass = () => {
const status = toolState()?.status || "pending"
return `tool-call-status-${status}`
@ -735,17 +873,6 @@ export default function ToolCall(props: ToolCallProps) {
setOutputSectionOverride(null)
})
const handleToggleInputVisibility = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!expanded()) {
toggle()
}
const currentlyVisible = isToolInputVisible()
setToolInputVisibilityOverride(currentlyVisible ? "hidden" : "expanded")
}
const renderer = createMemo(() => resolveToolRenderer(toolName()))
const renderMarkdownStub: ToolRendererContext["renderMarkdown"] = () => null
@ -765,10 +892,12 @@ export default function ToolCall(props: ToolCallProps) {
renderAnsi: renderAnsiStub,
renderDiff: renderDiffStub,
renderToolCall: renderToolCallStub,
outputWrapEnabled,
scrollHelpers: undefined,
}
const getRendererAction = () => renderer().getAction?.(headerRendererContext) ?? getDefaultToolAction(toolName())
const headerOutputChrome = createMemo(() => renderer().getOutputChrome?.(headerRendererContext) ?? {})
const renderToolTitle = () => {
@ -776,7 +905,17 @@ export default function ToolCall(props: ToolCallProps) {
const currentTool = toolName()
if (currentTool !== "task") {
return resolveTitleForTool({ toolName: currentTool, state })
if (!state || state.status === "pending") return getRendererAction()
const stateTitle = typeof (state as { title?: string }).title === "string" ? (state as { title?: string }).title : undefined
if (stateTitle && stateTitle.length > 0) {
return stateTitle
}
const customTitle = renderer().getTitle?.(headerRendererContext)
if (customTitle) return customTitle
return getToolName(currentTool)
}
if (!state) return getRendererAction()
@ -796,11 +935,37 @@ export default function ToolCall(props: ToolCallProps) {
return getToolName(currentTool)
}
const toolTypeLabel = createMemo(() => toolName())
const headerTitleDetail = createMemo(() => {
const rawTitle = renderToolTitle().trim()
const typeLabel = toolTypeLabel().trim()
if (!rawTitle) return ""
const labels = [typeLabel, getToolName(toolName()).trim()].filter(Boolean)
for (const label of labels) {
if (rawTitle === label) return ""
if (rawTitle.startsWith(`${label} `)) return rawTitle.slice(label.length).trimStart()
if (rawTitle.startsWith(`${label}[`)) return rawTitle.slice(label.length).trimStart()
if (rawTitle.startsWith(`${label} · `)) return rawTitle.slice(label.length + 3).trimStart()
}
return rawTitle
})
const headerText = createMemo(() => {
// Keep this as a memo so copy always matches what's rendered.
return renderToolTitle()
const typeLabel = toolTypeLabel()
const detail = headerTitleDetail()
return [typeLabel, detail].filter(Boolean).join(" ")
})
const headerCopyText = createMemo(() => headerOutputChrome().copyText || "")
const canCopyHeaderOutput = () => headerCopyText().length > 0
const canToggleOutputWrap = () => Boolean(headerOutputChrome().wrapToggle)
const outputWrapTitle = () =>
outputWrapEnabled()
? t("toolCall.diff.disableWordWrap")
: t("toolCall.diff.enableWordWrap")
const speechText = createMemo(() =>
buildToolSpeechText({
title: headerText(),
@ -819,12 +984,12 @@ export default function ToolCall(props: ToolCallProps) {
const handleCopyHeader = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const text = headerText()
const text = headerCopyText()
if (!text) return
await copyToClipboard(text)
}
const actionMenuItems = (): ActionOverflowMenuItem[] => {
const actionMenuItems = (includePrimaryActions = false): ActionOverflowMenuItem[] => {
const items: ActionOverflowMenuItem[] = []
if (hasToolInput()) {
@ -840,26 +1005,43 @@ export default function ToolCall(props: ToolCallProps) {
})
}
items.push({
key: "copy",
label: t("toolCall.header.copyTitle"),
icon: <Copy class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: async () => {
const text = headerText()
if (!text) return
await copyToClipboard(text)
},
})
if (includePrimaryActions) {
if (canCopyHeaderOutput()) {
items.push({
key: "copy",
label: t("toolCall.header.copyOutputTitle"),
icon: <Copy class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: async () => {
const text = headerCopyText()
if (!text) return
await copyToClipboard(text)
},
})
}
if (canSpeakToolCall()) {
items.push({
key: "speak",
label: speech.buttonTitle(),
icon: <Volume2 class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => void speech.toggle(),
})
if (canToggleOutputWrap()) {
items.push({
key: "toggle-output-wrap",
label: outputWrapTitle(),
icon: <WrapText class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => {
toggleOutputWrap()
},
})
}
if (canSpeakToolCall()) {
items.push({
key: "speak",
label: speech.buttonTitle(),
icon: <Volume2 class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => void speech.toggle(),
})
}
}
items.push(...(props.headerMenuItems?.() ?? []))
return items
}
@ -879,48 +1061,54 @@ export default function ToolCall(props: ToolCallProps) {
data-message-id={props.messageId}
data-part-id={toolCallIdentifier()}
>
<div class="tool-call-header" data-action-overflow={actionMenuItems().length > 1 ? "true" : undefined}>
<div class="tool-call-header" data-action-overflow={actionMenuItems(true).length > 0 ? "true" : undefined}>
<button
type="button"
class="tool-call-header-toggle"
onClick={toggle}
aria-expanded={expanded()}
>
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
{headerText()}
<span class="tool-call-disclosure" aria-hidden="true">{expanded() ? "▼" : "▶"}</span>
<span class="tool-call-summary">
<span class="tool-call-summary-type">{toolTypeLabel()}</span>
<Show when={headerTitleDetail()}>
{(detail) => <span class="tool-call-summary-title">{detail()}</span>}
</Show>
<ToolStatusIndicator status={status} />
</span>
</button>
<Show when={hasToolInput()}>
<Show when={canCopyHeaderOutput()}>
<button
type="button"
class="tool-call-header-input"
onClick={handleToggleInputVisibility}
aria-pressed={isToolInputVisible()}
aria-label={
isToolInputVisible()
? t("toolCall.header.hideInputAriaLabel")
: t("toolCall.header.showInputAriaLabel")
}
title={isToolInputVisible() ? t("toolCall.header.hideInputTitle") : t("toolCall.header.showInputTitle")}
class="tool-call-header-icon-button tool-call-header-copy"
onClick={handleCopyHeader}
aria-label={t("toolCall.header.copyOutputAriaLabel")}
title={t("toolCall.header.copyOutputTitle")}
>
<ArrowRightSquare class="w-3.5 h-3.5" />
<Copy class="w-3.5 h-3.5" />
</button>
</Show>
<button
type="button"
class="tool-call-header-copy"
onClick={handleCopyHeader}
aria-label={t("toolCall.header.copyAriaLabel")}
title={t("toolCall.header.copyTitle")}
>
<Copy class="w-3.5 h-3.5" />
</button>
<Show when={canToggleOutputWrap()}>
<button
type="button"
class={`tool-call-header-icon-button tool-call-header-copy tool-call-header-wrap${outputWrapEnabled() ? " active" : ""}`}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleOutputWrap()
}}
aria-label={outputWrapTitle()}
title={outputWrapTitle()}
>
<WrapText class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={canSpeakToolCall()}>
<SpeechActionButton
class="tool-call-header-copy"
class="tool-call-header-icon-button tool-call-header-copy"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
@ -928,14 +1116,22 @@ export default function ToolCall(props: ToolCallProps) {
/>
</Show>
<Show when={props.headerAction}>
{(action) => <span class="tool-call-header-action">{action()}</span>}
</Show>
<ActionOverflowMenu
items={actionMenuItems()}
label={t("messageItem.actions.more")}
triggerClass="tool-call-header-copy"
minItems={2}
triggerClass="tool-call-header-icon-button tool-call-header-copy action-overflow-wide"
minItems={1}
/>
<ActionOverflowMenu
items={actionMenuItems(true)}
label={t("messageItem.actions.more")}
triggerClass="tool-call-header-icon-button tool-call-header-copy action-overflow-narrow"
minItems={1}
/>
<ToolStatusIndicator status={status} />
</div>
<Show when={expanded()}>
@ -964,6 +1160,8 @@ export default function ToolCall(props: ToolCallProps) {
toolInput={toolInput}
inputSectionExpanded={inputSectionExpanded}
outputSectionExpanded={outputSectionExpanded}
outputWrapEnabled={outputWrapEnabled}
toggleOutputWrap={toggleOutputWrap}
toggleInputSection={toggleInputSection}
toggleOutputSection={toggleOutputSection}
toolCallRootEl={toolCallRootEl}

View file

@ -30,7 +30,7 @@ export function createMarkdownContentRenderer(params: {
const size = options.size || "default"
const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}${options.wrap ? " tool-call-markdown-wrap" : ""}`
const state = params.toolState()
const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending")
const registerRef = disableScrollTracking ? registerUntracked : registerTracked

View file

@ -15,24 +15,16 @@ export type PermissionToolBlockProps = {
submitting: Accessor<boolean>
error: Accessor<string | null>
onRespond: (permission: PermissionRequest, sessionId: string, response: PermissionResponse, message?: string) => void | Promise<void>
onRejectReasonOpenChange?: (open: boolean) => void
renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null
fallbackSessionId: Accessor<string>
}
export function PermissionToolBlock(props: PermissionToolBlockProps) {
const { t } = useI18n()
const [showRejectReason, setShowRejectReason] = createSignal(false)
const [rejectReason, setRejectReason] = createSignal("")
const setRejectReasonOpen = (open: boolean) => {
setShowRejectReason(open)
props.onRejectReasonOpenChange?.(open)
}
createEffect(() => {
props.permission()?.id
setRejectReasonOpen(false)
setRejectReason("")
})
@ -95,80 +87,50 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
<Show when={!props.active()}>
<p class="tool-call-permission-queued-text">{t("toolCall.permission.queuedText")}</p>
</Show>
<Show
when={showRejectReason()}
fallback={
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
onClick={() => respond("once")}
>
{t("toolCall.permission.actions.allowOnce")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
onClick={() => respond("always")}
>
{t("toolCall.permission.actions.alwaysAllow")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
onClick={() => setRejectReasonOpen(true)}
>
{t("toolCall.permission.actions.deny")}
</button>
</div>
<Show when={props.active()}>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>{t("toolCall.permission.shortcuts.allowOnce")}</span>
<kbd class="kbd">A</kbd>
<span>{t("toolCall.permission.shortcuts.alwaysAllow")}</span>
</div>
</Show>
</div>
}
>
<div class="tool-call-permission-reject-reason">
<label class="tool-call-permission-reject-label" for={`permission-reject-reason-${permission().id}`}>
{t("toolCall.permission.rejectReason.label")}
</label>
<textarea
id={`permission-reject-reason-${permission().id}`}
class="tool-call-permission-reject-textarea"
value={rejectReason()}
rows={3}
maxLength={PERMISSION_REJECT_REASON_MAX_LENGTH}
placeholder={t("toolCall.permission.rejectReason.placeholder")}
<div class="tool-call-permission-reject-reason">
<textarea
id={`permission-reject-reason-${permission().id}`}
class="tool-call-permission-reject-textarea"
value={rejectReason()}
rows={1}
maxLength={PERMISSION_REJECT_REASON_MAX_LENGTH}
placeholder={t("toolCall.permission.rejectReason.placeholder")}
aria-label={t("toolCall.permission.rejectReason.placeholder")}
disabled={props.submitting()}
onInput={(event) => setRejectReason(event.currentTarget.value)}
/>
</div>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
onInput={(event) => setRejectReason(event.currentTarget.value)}
/>
<p class="tool-call-permission-reject-hint">{t("toolCall.permission.rejectReason.hint")}</p>
<div class="tool-call-permission-buttons">
<button type="button" class="tool-call-permission-button" disabled={props.submitting()} onClick={confirmReject}>
{t("toolCall.permission.actions.confirmDeny")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
onClick={() => {
setRejectReasonOpen(false)
setRejectReason("")
}}
>
{t("toolCall.permission.actions.cancel")}
</button>
</div>
onClick={() => respond("once")}
>
{t("toolCall.permission.actions.allowOnce")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
onClick={() => respond("always")}
>
{t("toolCall.permission.actions.alwaysAllow")}
</button>
<button type="button" class="tool-call-permission-button" disabled={props.submitting()} onClick={confirmReject}>
{t("toolCall.permission.actions.deny")}
</button>
</div>
</Show>
<Show when={props.active()}>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>{t("toolCall.permission.shortcuts.allowOnce")}</span>
<kbd class="kbd">A</kbd>
<span>{t("toolCall.permission.shortcuts.alwaysAllow")}</span>
</div>
</Show>
</div>
<Show when={props.error()}>
<div class="tool-call-permission-error">{props.error()}</div>
</Show>

View file

@ -206,6 +206,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
const customSelected = () => selected().filter((value) => !optionLabels().has(value))
const customValue = () => customSelected()[0] ?? ""
const customChecked = () => customValue().length > 0
const showCustomAnswer = () => !hasFinalAnswers() || customChecked()
return (
<div class="border border-base bg-surface-secondary p-3 text-primary">
@ -249,57 +250,59 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
}}
</For>
<label
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
title={t("toolCall.question.custom.title")}
>
<input
ref={(el) => {
if (i() === 0 && (q?.options?.length ?? 0) === 0) firstInputRef = el
}}
type={inputType()}
name={groupName()}
class="mt-0.5 accent-[var(--accent-primary)]"
checked={customChecked()}
disabled={!props.active() || props.submitting()}
onChange={(e) => {
const container = e.currentTarget.closest("label")
const input = container?.querySelector("input[type='text']") as HTMLInputElement | null
if (!props.active()) return
if (customChecked()) {
clearCustomAnswer(i(), customSelected())
if (input) {
delete input.dataset.lastValue
}
return
}
toggleFromCustomInput(i(), input)
}}
/>
<div class="flex flex-1 flex-col gap-2">
<div class="text-sm leading-tight text-primary">{t("toolCall.question.custom.label")}</div>
<Show when={showCustomAnswer()}>
<label
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
title={t("toolCall.question.custom.title")}
>
<input
class="w-full rounded-md border border-base bg-surface-base px-2 py-1 text-sm text-primary"
type="text"
placeholder={t("toolCall.question.custom.placeholder")}
disabled={!props.active() || props.submitting()}
value={customValue()}
onFocus={(e) => {
if (!props.active()) return
// Keep the radio/checkbox selected while editing.
toggleFromCustomInput(i(), e.currentTarget)
ref={(el) => {
if (i() === 0 && (q?.options?.length ?? 0) === 0) firstInputRef = el
}}
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.isComposing) {
if (!submitDisabled()) {
props.onSubmit()
type={inputType()}
name={groupName()}
class="mt-0.5 accent-[var(--accent-primary)]"
checked={customChecked()}
disabled={!props.active() || props.submitting()}
onChange={(e) => {
const container = e.currentTarget.closest("label")
const input = container?.querySelector("input[type='text']") as HTMLInputElement | null
if (!props.active()) return
if (customChecked()) {
clearCustomAnswer(i(), customSelected())
if (input) {
delete input.dataset.lastValue
}
return
}
toggleFromCustomInput(i(), input)
}}
/>
</div>
</label>
<div class="flex flex-1 flex-col gap-2">
<div class="text-sm leading-tight text-primary">{t("toolCall.question.custom.label")}</div>
<input
class="w-full rounded-md border border-base bg-surface-base px-2 py-1 text-sm text-primary"
type="text"
placeholder={t("toolCall.question.custom.placeholder")}
disabled={!props.active() || props.submitting()}
value={customValue()}
onFocus={(e) => {
if (!props.active()) return
// Keep the radio/checkbox selected while editing.
toggleFromCustomInput(i(), e.currentTarget)
}}
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.isComposing) {
if (!submitDisabled()) {
props.onSubmit()
}
}
}}
/>
</div>
</label>
</Show>
</div>
</div>
)

View file

@ -62,6 +62,23 @@ export const applyPatchRenderer: ToolRenderer = {
}
return getToolName("apply_patch")
},
getOutputChrome({ toolState }) {
const state = toolState()
if (!state || state.status === "pending") return undefined
const payload = readToolStatePayload(state)
const files = Array.isArray((payload.metadata as any).files) ? ((payload.metadata as any).files as ApplyPatchFile[]) : []
const diffs = files
.map((file) => (typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""))
.filter((diff) => diff.trim().length > 0)
if (diffs.length > 0) {
return { language: "diff", copyText: diffs.join("\n"), suppressInnerHeader: false }
}
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
if (!fallback) return undefined
return { language: "text", copyText: fallback, wrapToggle: true, suppressInnerHeader: true }
},
renderBody({ toolState, renderDiff, renderMarkdown, t }) {
const state = toolState()
if (!state || state.status === "pending") return null

View file

@ -10,17 +10,36 @@ import { getBashToolSearchText } from "../search-text"
function RunningBashOutput(props: {
content: Accessor<string>
scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
}) {
let preRef: HTMLPreElement | undefined
let pendingRenderNotificationFrame: number | null = null
const updater = createStableAnsiStreamUpdater()
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
createEffect(() => {
const element = preRef
if (!element) return
updater.update(element, props.content())
props.scrollHelpers?.restoreAfterRender()
notifyContentRendered()
})
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
preRef = undefined
updater.reset()
})
@ -37,10 +56,64 @@ function RunningBashOutput(props: {
)
}
function FinalAnsiOutput(props: {
html: string
scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
}) {
let pendingRenderNotificationFrame: number | null = null
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
createEffect(() => {
props.html
props.scrollHelpers?.restoreAfterRender()
notifyContentRendered()
})
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
return (
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={props.html} />
</div>
)
}
function getBashCopyText(state: ToolState | undefined): string {
if (!state || state.status === "pending") return ""
const { input, metadata } = readToolStatePayload(state)
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(state)
? state.output
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
? metadata.output
: undefined,
)
return [command, outputResult?.text].filter(Boolean).join("\n")
}
function BashToolBody(props: {
toolState: Accessor<ToolState | undefined>
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
}) {
const state = createMemo(() => props.toolState())
@ -91,14 +164,12 @@ function BashToolBody(props: {
fallback={
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
{(html) => (
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
</div>
<FinalAnsiOutput html={html()} scrollHelpers={props.scrollHelpers} onContentRendered={props.onContentRendered} />
)}
</Show>
}
>
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} onContentRendered={props.onContentRendered} />
</Show>
</Show>
)
@ -124,7 +195,12 @@ export const bashRenderer: ToolRenderer = {
const timeoutLabel = `${timeout}ms`
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
},
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
getOutputChrome({ toolState }) {
const text = getBashCopyText(toolState())
if (!text) return undefined
return { language: "bash", copyText: text, wrapToggle: true, suppressInnerHeader: true }
},
renderBody({ toolState, renderMarkdown, scrollHelpers, onContentRendered }) {
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} onContentRendered={onContentRendered} />
},
}

View file

@ -5,7 +5,28 @@ import { getDefaultToolSearchText } from "../search-text"
export const defaultRenderer: ToolRenderer = {
tools: ["*"],
getSearchText: getDefaultToolSearchText,
renderBody({ toolState, renderMarkdown }) {
getOutputChrome({ toolState }) {
const state = toolState()
if (!state || state.status === "pending") return undefined
const { metadata, input } = readToolStatePayload(state)
const primaryOutput = isToolStateCompleted(state)
? state.output
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
? metadata.output
: metadata.diff ?? metadata.preview ?? input.content
const result = formatUnknown(primaryOutput)
if (!result) return undefined
return {
language: result.language ?? "text",
copyText: result.text,
wrapToggle: true,
suppressInnerHeader: true,
}
},
renderBody({ toolState, renderMarkdown, outputWrapEnabled }) {
const state = toolState()
if (!state || state.status === "pending") return null
@ -22,6 +43,6 @@ export const defaultRenderer: ToolRenderer = {
const content = ensureMarkdownContent(result.text, result.language, true)
if (!content) return null
return renderMarkdown({ content, disableHighlight: state.status === "running" })
return renderMarkdown({ content, disableHighlight: state.status === "running", wrap: outputWrapEnabled?.() ?? true })
},
}

View file

@ -15,6 +15,22 @@ export const editRenderer: ToolRenderer = {
if (!filePath) return getToolName("edit")
return `${getToolName("edit")} ${getRelativePath(filePath)}`
},
getOutputChrome({ toolState, toolName }) {
const state = toolState()
if (!state || state.status === "pending") return undefined
const diffPayload = extractDiffPayload(toolName(), state)
if (diffPayload) {
return { language: "diff", copyText: diffPayload.diffText, suppressInnerHeader: false }
}
const { metadata } = readToolStatePayload(state)
const diffText = typeof metadata.diff === "string" ? metadata.diff : null
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
const copyText = diffText || fallback
if (!copyText) return undefined
return { language: "diff", copyText, wrapToggle: true, suppressInnerHeader: true }
},
renderBody({ toolState, toolName, renderDiff, renderMarkdown }) {
const state = toolState()
if (!state || state.status === "pending") return null

View file

@ -1,40 +1,2 @@
import type { ToolRenderer } from "../types"
import { bashRenderer } from "./bash"
import { defaultRenderer } from "./default"
import { editRenderer } from "./edit"
import { applyPatchRenderer } from "./apply-patch"
import { patchRenderer } from "./patch"
import { readRenderer } from "./read"
import { taskRenderer } from "./task"
import { todoRenderer } from "./todo"
import { webfetchRenderer } from "./webfetch"
import { writeRenderer } from "./write"
import { invalidRenderer } from "./invalid"
import { questionRenderer } from "./question"
const TOOL_RENDERERS: ToolRenderer[] = [
bashRenderer,
readRenderer,
writeRenderer,
editRenderer,
applyPatchRenderer,
patchRenderer,
webfetchRenderer,
todoRenderer,
taskRenderer,
questionRenderer,
invalidRenderer,
]
const rendererMap = TOOL_RENDERERS.reduce<Record<string, ToolRenderer>>((acc, renderer) => {
renderer.tools.forEach((tool) => {
acc[tool] = renderer
})
return acc
}, {})
export function resolveToolRenderer(toolName: string): ToolRenderer {
return rendererMap[toolName] ?? defaultRenderer
}
export { defaultRenderer }
export { resolveToolRenderer } from "../tool-registry"
export { defaultRenderer } from "./default"

View file

@ -15,6 +15,9 @@ export const invalidRenderer: ToolRenderer = {
}
return getToolName("invalid")
},
getOutputChrome(context) {
return defaultRenderer.getOutputChrome?.(context)
},
renderBody(context) {
return defaultRenderer.renderBody(context)
},

View file

@ -15,6 +15,22 @@ export const patchRenderer: ToolRenderer = {
if (!filePath) return getToolName("patch")
return `${getToolName("patch")} ${getRelativePath(filePath)}`
},
getOutputChrome({ toolState, toolName }) {
const state = toolState()
if (!state || state.status === "pending") return undefined
const diffPayload = extractDiffPayload(toolName(), state)
if (diffPayload) {
return { language: "diff", copyText: diffPayload.diffText, suppressInnerHeader: false }
}
const { metadata } = readToolStatePayload(state)
const diffText = typeof metadata.diff === "string" ? metadata.diff : null
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
const copyText = diffText || fallback
if (!copyText) return undefined
return { language: "diff", copyText, wrapToggle: true, suppressInnerHeader: true }
},
renderBody({ toolState, toolName, renderDiff, renderMarkdown }) {
const state = toolState()
if (!state || state.status === "pending") return null

View file

@ -3,15 +3,23 @@ import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromP
import { tGlobal } from "../../../lib/i18n"
import { getReadToolSearchText } from "../search-text"
function getReadPath(input: Record<string, any>): string {
const filePath = typeof input.filePath === "string" ? input.filePath : ""
if (filePath) return filePath
const path = typeof input.path === "string" ? input.path : ""
if (path) return path
return typeof input.name === "string" ? input.name : ""
}
export const readRenderer: ToolRenderer = {
tools: ["read"],
getSearchText: getReadToolSearchText,
getAction: () => tGlobal("toolCall.renderer.action.readingFile"),
getTitle({ toolState }) {
getTitle({ toolName, toolState }) {
const state = toolState()
if (!state) return undefined
const { input } = readToolStatePayload(state)
const filePath = typeof input.filePath === "string" ? input.filePath : ""
const filePath = getReadPath(input)
const offset = typeof input.offset === "number" ? input.offset : undefined
const limit = typeof input.limit === "number" ? input.limit : undefined
const relativePath = filePath ? getRelativePath(filePath) : ""
@ -25,19 +33,29 @@ export const readRenderer: ToolRenderer = {
detailParts.push(tGlobal("toolCall.renderer.read.detail.limit", { limit }))
}
const baseTitle = relativePath ? `${getToolName("read")} ${relativePath}` : getToolName("read")
const toolLabel = getToolName(toolName())
const baseTitle = relativePath ? `${toolLabel} ${relativePath}` : toolLabel
if (!detailParts.length) {
return baseTitle
}
return `${baseTitle} · ${detailParts.join(" · ")}`
},
getOutputChrome({ toolState }) {
const state = toolState()
if (!state || state.status === "pending") return undefined
const { metadata, input } = readToolStatePayload(state)
const preview = typeof metadata.preview === "string" ? metadata.preview : null
if (!preview) return undefined
const language = inferLanguageFromPath(getReadPath(input)) ?? "text"
return { language, copyText: preview, wrapToggle: true, suppressInnerHeader: true }
},
renderBody({ toolState, renderMarkdown }) {
const state = toolState()
if (!state || state.status === "pending") return null
const { metadata, input } = readToolStatePayload(state)
const preview = typeof metadata.preview === "string" ? metadata.preview : null
const language = inferLanguageFromPath(typeof input.filePath === "string" ? input.filePath : undefined)
const language = inferLanguageFromPath(getReadPath(input))
const content = ensureMarkdownContent(preview, language, true)
if (!content) return null
return renderMarkdown({ content, disableHighlight: state.status === "running" })

View file

@ -0,0 +1,26 @@
import type { ToolRenderer } from "../types"
import { getToolName, readToolStatePayload } from "../utils"
import { getDefaultToolSearchText } from "../search-text"
import { defaultRenderer } from "./default"
export const searchRenderer: ToolRenderer = {
tools: ["glob", "grep"],
getSearchText: getDefaultToolSearchText,
getTitle({ toolName, toolState }) {
const state = toolState()
const name = getToolName(toolName())
if (!state) return name
const { input } = readToolStatePayload(state)
const pattern = typeof input.pattern === "string" ? input.pattern.trim() : ""
if (!pattern) return name
return `${name} ${pattern}`
},
getOutputChrome(context) {
return defaultRenderer.getOutputChrome?.(context)
},
renderBody(context) {
return defaultRenderer.renderBody(context)
},
}

View file

@ -0,0 +1,28 @@
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, formatUnknown, getToolName } from "../utils"
import { getDefaultToolSearchText } from "../search-text"
export const skillRenderer: ToolRenderer = {
tools: ["skill"],
getSearchText: getDefaultToolSearchText,
getTitle() {
return getToolName("skill")
},
getOutputChrome({ toolState }) {
const state = toolState()
if (!state || state.status !== "completed") return undefined
const output = formatUnknown(state.output)?.text ?? null
if (!output) return undefined
return { copyText: output, suppressInnerHeader: true }
},
renderBody({ toolState, renderMarkdown }) {
const state = toolState()
if (!state || state.status !== "completed") return null
const output = formatUnknown(state.output)?.text ?? null
const content = ensureMarkdownContent(output, undefined, false)
if (!content) return null
return <div class="tool-call-skill-body">{renderMarkdown({ content })}</div>
},
}

View file

@ -2,7 +2,6 @@ import { For, Index, Show, createEffect, createMemo, createSignal, untrack } fro
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
import { resolveTitleForTool } from "../tool-title"
import { messageStoreBus } from "../../../stores/message-v2/bus"
import { loadMessages } from "../../../stores/session-api"
import { loading, messagesLoaded } from "../../../stores/session-state"
@ -121,6 +120,24 @@ function describeTaskTitle(input: Record<string, any>) {
return base
}
function describeGenericToolTitle(tool: string, input: Record<string, any>) {
const base = getToolName(tool)
const detail =
typeof input.description === "string" && input.description.trim().length > 0
? input.description.trim()
: typeof input.filePath === "string" && input.filePath.trim().length > 0
? input.filePath.trim()
: typeof input.path === "string" && input.path.trim().length > 0
? input.path.trim()
: typeof input.url === "string" && input.url.trim().length > 0
? input.url.trim()
: typeof input.pattern === "string" && input.pattern.trim().length > 0
? input.pattern.trim()
: ""
return detail ? `${base} ${detail}` : base
}
function describeToolTitle(item: TaskSummaryItem): string {
if (item.title && item.title.length > 0) {
return item.title
@ -131,7 +148,12 @@ function describeToolTitle(item: TaskSummaryItem): string {
}
if (item.state) {
return resolveTitleForTool({ toolName: item.tool, state: item.state })
const stateTitle = typeof (item.state as { title?: string }).title === "string" ? (item.state as { title?: string }).title : undefined
if (stateTitle && stateTitle.length > 0) {
return stateTitle
}
const { input } = readToolStatePayload(item.state)
return describeGenericToolTitle(item.tool, { ...item.metadata, ...item.input, ...input })
}
return getDefaultToolAction(item.tool)

View file

@ -1,5 +1,6 @@
import { For, Show } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import { CheckCircle, CircleEllipsis, MinusCircle, PauseCircle } from "lucide-solid"
import type { ToolRenderer } from "../types"
import { readToolStatePayload } from "../utils"
import { useI18n, tGlobal } from "../../../lib/i18n"
@ -60,6 +61,19 @@ function getTodoStatusLabel(t: (key: string) => string, status: TodoViewStatus):
}
}
function TodoStatusIcon(props: { status: TodoViewStatus }) {
switch (props.status) {
case "completed":
return <CheckCircle class="tool-call-todo-icon" aria-hidden="true" />
case "in_progress":
return <CircleEllipsis class="tool-call-todo-icon" aria-hidden="true" />
case "cancelled":
return <MinusCircle class="tool-call-todo-icon" aria-hidden="true" />
default:
return <PauseCircle class="tool-call-todo-icon" aria-hidden="true" />
}
}
interface TodoListViewProps {
state?: ToolState
emptyLabel?: string
@ -87,20 +101,20 @@ export function TodoListView(props: TodoListViewProps) {
classList={{
"tool-call-todo-item-completed": todo.status === "completed",
"tool-call-todo-item-cancelled": todo.status === "cancelled",
"tool-call-todo-item-active": todo.status === "in_progress",
}}
role="listitem"
>
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
<div class="tool-call-todo-body">
<div class="tool-call-todo-heading">
<span class="tool-call-todo-text">{todo.content}</span>
<Show when={props.showStatusLabel !== false}>
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
</Show>
</div>
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}>
<TodoStatusIcon status={todo.status} />
</span>
<div class="tool-call-todo-body">
<div class="tool-call-todo-heading">
<span class="tool-call-todo-text">{todo.content}</span>
<Show when={props.showStatusLabel !== false}>
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
</Show>
</div>
</div>
</div>
)
}}
@ -123,7 +137,7 @@ export function getTodoTitle(state?: ToolState): string {
}
export const todoRenderer: ToolRenderer = {
tools: ["todowrite", "todoread"],
tools: ["todowrite"],
getSearchText: getTodoToolSearchText,
getAction: () => tGlobal("toolCall.renderer.action.planning"),
getTitle({ toolState }) {

View file

@ -16,6 +16,25 @@ export const webfetchRenderer: ToolRenderer = {
}
return getToolName("webfetch")
},
getOutputChrome({ toolState }) {
const state = toolState()
if (!state || state.status === "pending") return undefined
const { metadata } = readToolStatePayload(state)
const result = formatUnknown(
state.status === "completed"
? state.output
: metadata.output,
)
if (!result) return undefined
return {
language: result.language ?? "text",
copyText: result.text,
wrapToggle: true,
suppressInnerHeader: true,
}
},
renderBody({ toolState, renderMarkdown }) {
const state = toolState()
if (!state || state.status === "pending") return null

View file

@ -15,6 +15,20 @@ export const writeRenderer: ToolRenderer = {
if (!filePath) return getToolName("write")
return `${getToolName("write")} ${getRelativePath(filePath)}`
},
getOutputChrome({ toolState }) {
const state = toolState()
if (!state || state.status === "pending") return undefined
const { metadata, input } = readToolStatePayload(state)
const contentValue = typeof input.content === "string" ? input.content : metadata.content
if (typeof contentValue !== "string" || contentValue.length === 0) return undefined
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
return {
language: inferLanguageFromPath(filePath) ?? "text",
copyText: contentValue,
wrapToggle: true,
suppressInnerHeader: true,
}
},
renderBody({ toolState, renderMarkdown }) {
const state = toolState()
if (!state || state.status === "pending") return null

View file

@ -75,6 +75,8 @@ export function getReadToolSearchText(context: ToolSearchTextContext): string[]
const { input, metadata } = readToolStatePayload(context.toolState)
appendBaseToolText(values, context)
appendString(values, input.filePath)
appendString(values, input.path)
appendString(values, input.name)
appendString(values, metadata.preview)
appendToolErrorText(values, context)
return values

View file

@ -0,0 +1,204 @@
import type { ExpansionPreference, Preferences, ToolCallExpansionPreset } from "../../stores/preferences"
import type { ToolRenderer } from "./types"
import { applyPatchRenderer } from "./renderers/apply-patch"
import { bashRenderer } from "./renderers/bash"
import { defaultRenderer } from "./renderers/default"
import { editRenderer } from "./renderers/edit"
import { invalidRenderer } from "./renderers/invalid"
import { patchRenderer } from "./renderers/patch"
import { questionRenderer } from "./renderers/question"
import { readRenderer } from "./renderers/read"
import { searchRenderer } from "./renderers/search"
import { skillRenderer } from "./renderers/skill"
import { taskRenderer } from "./renderers/task"
import { todoRenderer } from "./renderers/todo"
import { webfetchRenderer } from "./renderers/webfetch"
import { writeRenderer } from "./renderers/write"
export const OTHER_TOOL_NAME = "other"
export const THINKING_EXPANSION_PRESETS: Record<ToolCallExpansionPreset, ExpansionPreference> = {
minimal: "collapsed",
balanced: "collapsed",
detailed: "expanded",
everything: "expanded",
}
export interface ToolRegistryEntry {
tool: string
label: string
labelKey?: string
renderer: ToolRenderer
configurable: boolean
expansionPresets: Record<ToolCallExpansionPreset, ExpansionPreference>
aliases?: string[]
}
const expanded = "expanded" satisfies ExpansionPreference
const collapsed = "collapsed" satisfies ExpansionPreference
function presets(values: Record<ToolCallExpansionPreset, ExpansionPreference>) {
return values
}
export const TOOL_REGISTRY: ToolRegistryEntry[] = [
{
tool: "bash",
label: "bash",
renderer: bashRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: expanded, detailed: expanded, everything: expanded }),
},
{
tool: "read",
label: "read",
renderer: readRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: collapsed, detailed: collapsed, everything: expanded }),
},
{
tool: "write",
label: "write",
renderer: writeRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: expanded, detailed: expanded, everything: expanded }),
},
{
tool: "edit",
label: "edit",
renderer: editRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: expanded, detailed: expanded, everything: expanded }),
},
{
tool: "patch",
label: "patch",
renderer: patchRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: expanded, detailed: expanded, everything: expanded }),
},
{
tool: "apply_patch",
label: "apply_patch",
renderer: applyPatchRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: expanded, detailed: expanded, everything: expanded }),
},
{
tool: "webfetch",
label: "webfetch",
renderer: webfetchRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: collapsed, detailed: expanded, everything: expanded }),
},
{
tool: "glob",
label: "glob",
renderer: searchRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: collapsed, detailed: expanded, everything: expanded }),
},
{
tool: "grep",
label: "grep",
renderer: searchRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: collapsed, detailed: expanded, everything: expanded }),
},
{
tool: "todowrite",
label: "todowrite",
renderer: todoRenderer,
configurable: true,
expansionPresets: presets({ minimal: expanded, balanced: expanded, detailed: expanded, everything: expanded }),
},
{
tool: "task",
label: "task",
renderer: taskRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: expanded, detailed: expanded, everything: expanded }),
},
{
tool: "skill",
label: "skill",
renderer: skillRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: collapsed, detailed: collapsed, everything: expanded }),
},
{
tool: "question",
label: "question",
renderer: questionRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: expanded, detailed: expanded, everything: expanded }),
},
{
tool: "invalid",
label: "invalid",
renderer: invalidRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: collapsed, detailed: collapsed, everything: expanded }),
},
{
tool: OTHER_TOOL_NAME,
label: "Other tools",
labelKey: "settings.behavior.expansionDefaults.otherTools",
renderer: defaultRenderer,
configurable: true,
expansionPresets: presets({ minimal: collapsed, balanced: collapsed, detailed: expanded, everything: expanded }),
},
]
const otherToolEntry = TOOL_REGISTRY.find((entry) => entry.tool === OTHER_TOOL_NAME)!
const registryMap = TOOL_REGISTRY.reduce<Record<string, ToolRegistryEntry>>((acc, entry) => {
acc[entry.tool] = entry
entry.aliases?.forEach((alias) => {
acc[alias] = entry
})
return acc
}, {})
export function getToolRegistryEntry(toolName: string): ToolRegistryEntry {
return registryMap[toolName] ?? otherToolEntry
}
export function resolveToolRenderer(toolName: string): ToolRenderer {
return getToolRegistryEntry(toolName).renderer
}
export function getConfigurableToolEntries(): ToolRegistryEntry[] {
return TOOL_REGISTRY.filter((entry) => entry.configurable)
}
export function buildToolExpansionPresetDefaults(preset: ToolCallExpansionPreset): Record<string, ExpansionPreference> {
const defaults: Record<string, ExpansionPreference> = {}
for (const entry of getConfigurableToolEntries()) {
defaults[entry.tool] = entry.expansionPresets[preset]
}
return defaults
}
export function resolveToolExpansionDefault(preferences: Preferences, toolName: string): boolean {
const entry = getToolRegistryEntry(toolName)
const defaults = preferences.toolCallExpansionDefaults
const presetMode = defaults.preset === "custom" ? undefined : entry.expansionPresets[defaults.preset]
const otherPresetMode = defaults.preset === "custom" ? undefined : otherToolEntry.expansionPresets[defaults.preset]
const mode = defaults.tools[entry.tool]
?? presetMode
?? defaults.tools[OTHER_TOOL_NAME]
?? otherPresetMode
?? preferences.toolOutputExpansion
?? "expanded"
return mode === "expanded"
}
export function resolveThinkingExpansionDefault(preferences: Preferences): boolean {
const defaults = preferences.toolCallExpansionDefaults
const mode = defaults.thinking
?? (defaults.preset === "custom" ? undefined : THINKING_EXPANSION_PRESETS[defaults.preset])
?? preferences.thinkingBlocksExpansion
?? "expanded"
return mode === "expanded"
}

View file

@ -1,111 +0,0 @@
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
import { enMessages } from "../../lib/i18n/messages/en"
import { defaultRenderer } from "./renderers/default"
import { bashRenderer } from "./renderers/bash"
import { readRenderer } from "./renderers/read"
import { writeRenderer } from "./renderers/write"
import { editRenderer } from "./renderers/edit"
import { applyPatchRenderer } from "./renderers/apply-patch"
import { patchRenderer } from "./renderers/patch"
import { webfetchRenderer } from "./renderers/webfetch"
import { todoRenderer } from "./renderers/todo"
import { invalidRenderer } from "./renderers/invalid"
const TITLE_RENDERERS: Record<string, ToolRenderer> = {
bash: bashRenderer,
read: readRenderer,
write: writeRenderer,
edit: editRenderer,
apply_patch: applyPatchRenderer,
patch: patchRenderer,
webfetch: webfetchRenderer,
todowrite: todoRenderer,
todoread: todoRenderer,
invalid: invalidRenderer,
}
interface TitleSnapshot {
toolName: string
state?: ToolState
}
function lookupRenderer(toolName: string): ToolRenderer {
return TITLE_RENDERERS[toolName] ?? defaultRenderer
}
function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
return {
id: "",
type: "tool",
tool: snapshot.toolName,
state: snapshot.state,
} as ToolCallPart
}
function interpolate(template: string, params?: Record<string, unknown>): string {
if (!params) return template
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
const value = params[key]
return value === undefined || value === null ? "" : String(value)
})
}
function createStaticT(): ToolRendererContext["t"] {
return (key, params) => {
const template = (enMessages as Record<string, string>)[key] ?? key
return interpolate(template, params)
}
}
function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
const toolStateAccessor = () => snapshot.state
const toolNameAccessor = () => snapshot.toolName
const toolCallAccessor = () => createStaticToolPart(snapshot)
const messageVersionAccessor = () => undefined
const partVersionAccessor = () => undefined
const t = createStaticT()
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
const renderDiff: ToolRendererContext["renderDiff"] = () => null
return {
toolCall: toolCallAccessor,
toolState: toolStateAccessor,
toolName: toolNameAccessor,
instanceId: "",
sessionId: "",
t,
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown,
renderAnsi,
renderDiff,
renderToolCall: () => null,
scrollHelpers: undefined,
}
}
export function resolveTitleForTool(snapshot: TitleSnapshot): string {
const renderer = lookupRenderer(snapshot.toolName)
const context = createStaticContext(snapshot)
const state = snapshot.state
const defaultAction = renderer.getAction?.(context) ?? getDefaultToolAction(snapshot.toolName)
if (!state || state.status === "pending") {
return defaultAction
}
const stateTitle = typeof (state as { title?: string }).title === "string" ? (state as { title?: string }).title : undefined
if (stateTitle && stateTitle.length > 0) {
return stateTitle
}
const customTitle = renderer.getTitle?.(context)
if (customTitle) {
return customTitle
}
return getToolName(snapshot.toolName)
}

View file

@ -13,6 +13,7 @@ export interface MarkdownRenderOptions {
content: string
size?: "default" | "large"
disableHighlight?: boolean
wrap?: boolean
/**
* Optional suffix to avoid render-cache collisions when a tool call renders
* multiple markdown regions (e.g. task prompt vs task output).
@ -74,6 +75,7 @@ export interface ToolRendererContext {
sessionId: string
forceCollapsed?: boolean
}) => JSXElement | null
outputWrapEnabled?: Accessor<boolean>
scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
}
@ -88,6 +90,7 @@ export interface ToolRenderer {
tools: string[]
getTitle?(context: ToolRendererContext): string | undefined
getAction?(context: ToolRendererContext): string | undefined
getOutputChrome?(context: ToolRendererContext): ToolOutputChrome | undefined
/**
* Text that is visible or directly revealable through this renderer. Keep this
* in sync with custom renderBody output when adding specialized tool UIs.
@ -96,4 +99,13 @@ export interface ToolRenderer {
renderBody(context: ToolRendererContext): JSXElement | null
}
export interface ToolOutputChrome {
title?: string
language?: string
copyText?: string | null
actions?: JSXElement
wrapToggle?: boolean
suppressInnerHeader?: boolean
}
export type ToolRendererMap = Record<string, ToolRenderer>

View file

@ -44,7 +44,6 @@ export function getToolIcon(tool: string): string {
case "task":
return "🎯"
case "todowrite":
case "todoread":
return "📋"
case "question":
return "❓"
@ -68,7 +67,6 @@ export function getToolName(tool: string): string {
case "invalid":
return tGlobal("toolCall.renderer.toolName.invalid")
case "todowrite":
case "todoread":
return tGlobal("toolCall.renderer.toolName.plan")
case "apply_patch":
return tGlobal("toolCall.renderer.toolName.applyPatch")
@ -221,7 +219,6 @@ export function getDefaultToolAction(toolName: string) {
case "write":
return tGlobal("toolCall.renderer.action.preparingWrite")
case "todowrite":
case "todoread":
return tGlobal("toolCall.renderer.action.planning")
case "patch":
return tGlobal("toolCall.renderer.action.preparingPatch")

View file

@ -5,6 +5,8 @@ import {
VirtualScrollController,
isAtBottom,
isAutoFollowing,
resolveAutoPinHoldElement,
shouldSuspendAutoPinToBottomForHold,
transitionFollowMode,
type FollowMode,
type ScrollControllerMetrics,
@ -37,11 +39,11 @@ describe("virtual follow behavior", () => {
assert.deepEqual(next.effect, { type: "none" })
})
it("rejoins follow and pins bottom when escaped user scrolls down with pin permission", () => {
it("does not rejoin follow above bottom even with pin permission", () => {
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false, true))
assert.deepEqual(next.mode, { type: "following" })
assert.deepEqual(next.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
assert.deepEqual(next.mode, { type: "escaped" })
assert.deepEqual(next.effect, { type: "none" })
})
it("rejoins follow when escaped user scrolls to the bottom", () => {
@ -51,10 +53,10 @@ describe("virtual follow behavior", () => {
assert.deepEqual(next.effect, { type: "none" })
})
it("releases hold when the user scrolls down above bottom", () => {
it("keeps hold latched when the user scrolls down above bottom", () => {
const next = transitionFollowMode({ type: "holding", key: "message-1" }, userScroll("down", false, true))
assert.deepEqual(next.mode, { type: "escaped" })
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
assert.deepEqual(next.effect, { type: "none" })
})
@ -72,10 +74,10 @@ describe("virtual follow behavior", () => {
assert.deepEqual(next.effect, { type: "none" })
})
it("releases hold for directionless user scroll away from bottom", () => {
it("keeps hold latched for directionless user scroll away from bottom", () => {
const next = transitionFollowMode({ type: "holding", key: "message-1" }, userScroll(null, false, true))
assert.deepEqual(next.mode, { type: "escaped" })
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
assert.deepEqual(next.effect, { type: "none" })
})
@ -87,11 +89,11 @@ describe("virtual follow behavior", () => {
assert.deepEqual(following.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
})
it("maintains hold alignment while held content grows", () => {
it("does not align or pin while held content grows", () => {
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "content-grew", canPinToBottom: true })
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
assert.deepEqual(next.effect, { type: "align-hold", key: "message-1" })
assert.deepEqual(next.effect, { type: "none" })
})
it("enters hold mode for a valid hold candidate", () => {
@ -101,6 +103,20 @@ describe("virtual follow behavior", () => {
assert.deepEqual(next.effect, { type: "align-hold", key: "message-1" })
})
it("keeps hold latched when the hold target disappears", () => {
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "hold-target-changed", key: null, canPinToBottom: true })
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
assert.deepEqual(next.effect, { type: "none" })
})
it("keeps hold latched when a later hold target is reported", () => {
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "hold-target-changed", key: "message-2", canPinToBottom: true })
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
assert.deepEqual(next.effect, { type: "none" })
})
it("explicit bottom jumps leave hold and suppress the next hold", () => {
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "jump-bottom", immediate: true, explicit: true })
@ -108,6 +124,80 @@ describe("virtual follow behavior", () => {
assert.deepEqual(next.effect, { type: "scroll-bottom", immediate: true, suppressHold: true })
})
it("prompt submission overrides a stale hold latch and returns to bottom follow", () => {
const controller = new VirtualScrollController(true)
controller.holdCandidate("old-assistant-answer", true)
const result = controller.jumpBottom(true, true)
assert.deepEqual(result.state.mode, { type: "following" })
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: true })
assert.equal(controller.isAutoFollowing(), true)
})
it("clears an existing hold latch when hold targeting is disabled", () => {
const controller = new VirtualScrollController(true)
controller.holdCandidate("old-assistant-answer", true)
const result = controller.clearHold(true, true, true)
assert.deepEqual(result.state.mode, { type: "following" })
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: true })
})
it("keeps submitted prompt content growth in bottom-follow after clearing stale hold", () => {
const controller = new VirtualScrollController(true)
controller.holdCandidate("old-assistant-answer", true)
controller.jumpBottom(true, true)
const result = controller.contentRendered(metrics(2400), true)
assert.deepEqual(result.state.mode, { type: "following" })
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
})
it("ignores stale previous assistant hold target changes after a submit bottom jump", () => {
const controller = new VirtualScrollController(true)
controller.holdCandidate("previous-assistant-answer", true)
controller.jumpBottom(true, true)
const targetChanged = controller.holdTargetChanged("previous-assistant-answer", true)
const contentRendered = controller.contentRendered(metrics(2400), true)
assert.deepEqual(targetChanged.state.mode, { type: "following" })
assert.deepEqual(targetChanged.effect, { type: "none" })
assert.deepEqual(contentRendered.state.mode, { type: "following" })
assert.deepEqual(contentRendered.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
})
it("keeps escaped-mode streaming detached until actual bottom", () => {
const suspend = shouldSuspendAutoPinToBottomForHold({
externalSuspend: false,
activeHoldTargetKey: null,
eligibleHoldTargetKey: "streaming-assistant-answer",
})
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false, !suspend))
assert.equal(suspend, false)
assert.deepEqual(next.mode, { type: "escaped" })
assert.deepEqual(next.effect, { type: "none" })
})
it("keeps auto-pin suspended while a hold target is actively latched", () => {
const suspend = shouldSuspendAutoPinToBottomForHold({
externalSuspend: false,
activeHoldTargetKey: "streaming-assistant-answer",
eligibleHoldTargetKey: "streaming-assistant-answer",
})
const next = transitionFollowMode({ type: "holding", key: "streaming-assistant-answer" }, userScroll("down", false, !suspend))
assert.equal(suspend, true)
assert.deepEqual(next.mode, { type: "holding", key: "streaming-assistant-answer" })
assert.deepEqual(next.effect, { type: "none" })
})
it("key jumps can opt into follow or escape mode", () => {
const follow = transitionFollowMode({ type: "escaped" }, { type: "jump-key", key: "a", block: "start", smooth: false, followAfter: true })
const escape = transitionFollowMode({ type: "following" }, { type: "jump-key", key: "b", block: "center", smooth: true, followAfter: false })
@ -119,7 +209,7 @@ describe("virtual follow behavior", () => {
it("derives auto-follow from modes", () => {
const modes: Array<[FollowMode, boolean]> = [
[{ type: "following" }, true],
[{ type: "holding", key: "message-1" }, true],
[{ type: "holding", key: "message-1" }, false],
[{ type: "escaped" }, false],
]
@ -138,14 +228,24 @@ describe("virtual follow behavior", () => {
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
})
it("maintains hold alignment when held content renders", () => {
it("does not align or pin when held content renders", () => {
const controller = new VirtualScrollController(true)
controller.holdCandidate("message-1", true)
const result = controller.contentRendered(metrics(2200), true)
assert.deepEqual(result.state.mode, { type: "holding", key: "message-1" })
assert.deepEqual(result.effect, { type: "align-hold", key: "message-1" })
assert.deepEqual(result.effect, { type: "none" })
})
it("does not resume or snap when a held target disappears", () => {
const controller = new VirtualScrollController(true)
controller.holdCandidate("message-1", true)
const result = controller.holdTargetChanged(null, true)
assert.deepEqual(result.state.mode, { type: "holding", key: "message-1" })
assert.deepEqual(result.effect, { type: "none" })
})
it("lets fresh user upward movement escape even during a programmatic window", () => {
@ -189,6 +289,17 @@ describe("virtual follow behavior", () => {
assert.deepEqual(result.effect, { type: "none" })
})
it("does not magnet to bottom above bottom even with integration pin permission", () => {
const controller = new VirtualScrollController(false)
controller.recordProgrammaticOffset(2100, false)
controller.setUserIntent("down", 700)
const result = controller.observeViewport(metrics(2220), 100, false, true)
assert.deepEqual(result.state.mode, { type: "escaped" })
assert.deepEqual(result.effect, { type: "none" })
})
it("resumes follow only when downward movement reaches actual bottom", () => {
const controller = new VirtualScrollController(false)
controller.recordProgrammaticOffset(2300, false)
@ -200,6 +311,24 @@ describe("virtual follow behavior", () => {
assert.deepEqual(result.effect, { type: "none" })
})
it("keeps hold latched until downward movement reaches actual bottom", () => {
const controller = new VirtualScrollController(true)
controller.holdCandidate("message-1", true)
controller.recordProgrammaticOffset(2100, false)
controller.setUserIntent("down", 700)
const nearBottom = controller.observeViewport(metrics(2220), 100, false, true)
assert.deepEqual(nearBottom.state.mode, { type: "holding", key: "message-1" })
assert.deepEqual(nearBottom.effect, { type: "none" })
controller.setUserIntent("down", 800)
const atBottom = controller.observeViewport(metrics(2400), 200, false, true)
assert.deepEqual(atBottom.state.mode, { type: "following" })
assert.deepEqual(atBottom.effect, { type: "none" })
})
it("still escapes follow on upward movement at bottom", () => {
const controller = new VirtualScrollController(true)
controller.recordProgrammaticOffset(1200, false)
@ -245,4 +374,13 @@ describe("virtual follow behavior", () => {
assert.equal(isAtBottom(closeButNotAtBottom), false)
})
it("excludes reasoning-only hold targets while preserving Assistant text eligibility", () => {
const itemWrapper = { id: "message-wrapper" } as unknown as HTMLElement
const assistantAnswerText = { id: "assistant-answer-text" } as unknown as HTMLElement
assert.equal(resolveAutoPinHoldElement(itemWrapper, "message-1", () => null), null)
assert.equal(resolveAutoPinHoldElement(itemWrapper, "message-1", () => assistantAnswerText), assistantAnswerText)
assert.equal(resolveAutoPinHoldElement(itemWrapper, "message-1", () => undefined), itemWrapper)
})
})

View file

@ -18,6 +18,7 @@ export type FollowEvent =
| { type: "content-grew"; canPinToBottom: boolean }
| { type: "hold-candidate"; key: string; shouldHold: boolean }
| { type: "hold-target-changed"; key: string | null; canPinToBottom: boolean }
| { type: "clear-hold"; follow: boolean; canPinToBottom: boolean; suppressHold: boolean }
| { type: "set-follow"; enabled: boolean }
| { type: "reset"; follow: boolean }
@ -58,35 +59,107 @@ export interface ScrollControllerSnapshot {
restoring: boolean
}
export interface FollowSnapshotState {
followModeType?: FollowMode["type"]
heldKey?: string
holdAnchorSuspended?: boolean
}
export type HoldTargetElementResolver = (itemWrapper: HTMLElement, key: string) => HTMLElement | null | undefined
const noFollowEffect: FollowEffect = { type: "none" }
export function isAutoFollowing(mode: FollowMode) {
return mode.type === "following" || mode.type === "holding"
return mode.type === "following"
}
export function getHeldKey(mode: FollowMode) {
return mode.type === "holding" ? mode.key : null
}
export function resolveAutoPinHoldElement(
itemWrapper: HTMLElement | null | undefined,
key: string,
resolver?: HoldTargetElementResolver,
) {
if (!itemWrapper) return null
if (!resolver) return itemWrapper
const resolved = resolver(itemWrapper, key)
return resolved === undefined ? itemWrapper : resolved
}
export function shouldSuspendAutoPinToBottomForHold(state: {
externalSuspend: boolean
activeHoldTargetKey: string | null
eligibleHoldTargetKey?: string | null
}) {
return state.externalSuspend || state.activeHoldTargetKey !== null
}
export function shouldTrackHeldAnchor(state: {
activeHoldTargetKey: string | null
eligibleHoldTargetKey: string | null
suspendedByUser: boolean
}) {
return !state.suspendedByUser && state.activeHoldTargetKey !== null && state.activeHoldTargetKey === state.eligibleHoldTargetKey
}
export function isSnapshotAutoFollowing(snapshot: { atBottom: boolean; followModeType?: FollowMode["type"] } | null | undefined) {
if (!snapshot) return true
if (snapshot.followModeType) return snapshot.followModeType === "following"
return snapshot.atBottom
}
export function getFollowSnapshotState(mode: FollowMode, holdAnchorSuspended: boolean): FollowSnapshotState {
if (mode.type === "holding") {
return {
followModeType: "holding",
heldKey: mode.key,
holdAnchorSuspended: holdAnchorSuspended || undefined,
}
}
return { followModeType: mode.type }
}
export function restoreFollowModeFromSnapshot(state: {
atBottom: boolean
followModeType?: FollowMode["type"]
heldKey?: string
hasHeldKeyItem?: boolean
}): FollowMode {
if (state.followModeType === "holding" && state.heldKey && state.hasHeldKeyItem) {
return { type: "holding", key: state.heldKey }
}
if (state.followModeType === "following") {
return { type: "following" }
}
if (state.followModeType === "escaped") {
return { type: "escaped" }
}
return state.atBottom ? { type: "following" } : { type: "escaped" }
}
export function transitionFollowMode(mode: FollowMode, event: FollowEvent): FollowTransition {
switch (event.type) {
case "user-scroll": {
if (mode.type === "holding") {
if (event.atBottom && event.direction !== "up") {
return { mode: { type: "following" }, effect: noFollowEffect }
}
return { mode, effect: noFollowEffect }
}
if (event.direction === "up") {
return { mode: { type: "escaped" }, effect: noFollowEffect }
}
if (mode.type === "holding" && event.direction === null) {
return { mode: { type: "escaped" }, effect: noFollowEffect }
}
if (mode.type === "holding" && event.direction === "down") {
return { mode: { type: "escaped" }, effect: noFollowEffect }
}
if (mode.type === "escaped" && event.direction === "down" && event.canPinToBottom) {
if (mode.type === "escaped" && event.direction === "down" && event.atBottom && event.canPinToBottom) {
return {
mode: { type: "following" },
effect: { type: "scroll-bottom", immediate: true, suppressHold: false },
}
}
if (event.atBottom && mode.type !== "holding") {
if (event.atBottom) {
return { mode: { type: "following" }, effect: noFollowEffect }
}
return { mode, effect: noFollowEffect }
@ -111,9 +184,6 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll
if (mode.type === "following" && event.canPinToBottom) {
return { mode, effect: { type: "scroll-bottom", immediate: true, suppressHold: false } }
}
if (mode.type === "holding" && event.canPinToBottom) {
return { mode, effect: { type: "align-hold", key: mode.key } }
}
return { mode, effect: noFollowEffect }
case "hold-candidate":
@ -123,12 +193,18 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll
return { mode, effect: noFollowEffect }
case "hold-target-changed":
if (mode.type !== "holding" || event.key === mode.key) {
return { mode, effect: noFollowEffect }
case "clear-hold":
if (mode.type !== "holding") {
return { mode, effect: noFollowEffect }
}
return {
mode: { type: "following" },
effect: event.canPinToBottom ? { type: "scroll-bottom", immediate: false, suppressHold: false } : noFollowEffect,
mode: event.follow ? { type: "following" } : { type: "escaped" },
effect:
event.follow && event.canPinToBottom
? { type: "scroll-bottom", immediate: true, suppressHold: event.suppressHold }
: noFollowEffect,
}
case "set-follow":
@ -205,6 +281,11 @@ export class VirtualScrollController {
return this.result(next.effect)
}
restoreMode(mode: FollowMode): ScrollControllerResult {
this.state.mode = mode
return this.result(noFollowEffect)
}
jumpTop(immediate: boolean): ScrollControllerResult {
const next = transitionFollowMode(this.state.mode, { type: "jump-top", immediate })
this.state.mode = next.mode
@ -235,7 +316,19 @@ export class VirtualScrollController {
return this.result(next.effect)
}
observeViewport(metrics: ScrollControllerMetrics, now: number, programmatic: boolean, canPinToBottom = false): ScrollControllerResult {
clearHold(follow: boolean, canPinToBottom: boolean, suppressHold: boolean): ScrollControllerResult {
const next = transitionFollowMode(this.state.mode, { type: "clear-hold", follow, canPinToBottom, suppressHold })
this.state.mode = next.mode
return this.result(next.effect)
}
observeViewport(
metrics: ScrollControllerMetrics,
now: number,
programmatic: boolean,
canPinToBottom = false,
forceEscapeFromUpScroll = false,
): ScrollControllerResult {
const previousOffset = this.state.lastObservedOffset
const offset = metrics.offset
const scrolledUp = offset < previousOffset - 1
@ -247,7 +340,13 @@ export class VirtualScrollController {
this.clearExpiredUserIntent(now)
const hasFreshIntent = now <= this.state.userIntentUntil
if (scrolledUp && this.isAutoFollowing() && !atBottom && this.heldKey() === null && (!programmatic || hasFreshIntent)) {
if (
scrolledUp &&
this.isAutoFollowing() &&
(forceEscapeFromUpScroll || !atBottom) &&
this.heldKey() === null &&
(!programmatic || hasFreshIntent)
) {
return this.setFollow(false)
}

View file

@ -1,14 +1,20 @@
import { Show, createEffect, createMemo, createSignal, type Accessor, type JSX, on, onCleanup } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import { getHeldKey, isAutoFollowing, isAtBottom, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts"
import { getFollowSnapshotState, getHeldKey, isAutoFollowing, isAtBottom, resolveAutoPinHoldElement, restoreFollowModeFromSnapshot, shouldSuspendAutoPinToBottomForHold, shouldTrackHeldAnchor, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
const DEFAULT_REJOIN_LAST_ITEM_COUNT = 2
const USER_SCROLL_INTENT_WINDOW_MS = 600
const BOTTOM_FOLLOW_INTENT_SETTLE_FRAMES = 4
const BOTTOM_FOLLOW_INTENT_MAX_FRAMES = 60
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
const INTERACTIVE_KEY_TARGET_SELECTOR = "button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='textbox']"
export interface VirtualFollowBottomIntent {
token: string | number
minItemCount?: number
}
export interface VirtualFollowListApi {
scrollToTop: (opts?: { immediate?: boolean }) => void
scrollToBottom: (opts?: { immediate?: boolean; suppressAutoAnchor?: boolean; suppressHold?: boolean }) => void
@ -32,6 +38,9 @@ export interface VirtualFollowScrollSnapshot {
anchorKey?: string
anchorOffset?: number
atBottom: boolean
followModeType?: FollowMode["type"]
heldKey?: string
holdAnchorSuspended?: boolean
}
interface RestoreScrollSnapshotOptions {
@ -65,6 +74,7 @@ export interface VirtualFollowListProps<T> {
suspendMeasurements?: Accessor<boolean>
streamingActive?: Accessor<boolean>
isActive?: Accessor<boolean>
autoPinHoldEnabled?: Accessor<boolean>
/**
* When switching back to an inactive (cached) pane, the list historically
@ -100,6 +110,12 @@ export interface VirtualFollowListProps<T> {
*/
followToken?: Accessor<string | number>
/**
* Explicit submit intent that temporarily overrides stale hold/restore state
* until the newly submitted exchange has mounted.
*/
forceBottomFollowIntent?: Accessor<VirtualFollowBottomIntent | null>
/**
* Optional item key whose geometry can temporarily hold auto-follow when the
* rendered item grows taller than the viewport and reaches the top edge.
@ -108,9 +124,10 @@ export interface VirtualFollowListProps<T> {
/**
* Optional resolver for the specific element inside an item wrapper that
* should be measured for hold-target geometry.
* should be measured for hold-target geometry. Return null when the item has
* no eligible hold target; return undefined to fall back to the item wrapper.
*/
resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined
resolveAutoPinHoldElement?: HoldTargetElementResolver
/**
* Top-edge threshold for the hold target in pixels.
@ -169,7 +186,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false)
const streamingActive = () => props.streamingActive?.() ?? false
const autoPinHoldEnabled = () => props.autoPinHoldEnabled?.() ?? true
const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null)
const forceBottomFollowIntent = () => props.forceBottomFollowIntent?.() ?? null
const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX
const scrollController = new VirtualScrollController(initialAutoScroll())
@ -180,7 +199,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const [activeKey, setActiveKey] = createSignal<string | null>(null)
const activeHoldTargetKey = createMemo(() => getHeldKey(followMode()))
const [didTriggerHoldForCurrentTarget, setDidTriggerHoldForCurrentTarget] = createSignal(false)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || activeHoldTargetKey() !== null
const effectiveSuspendAutoPinToBottom = () => shouldSuspendAutoPinToBottomForHold({
externalSuspend: externalSuspendAutoPinToBottom(),
activeHoldTargetKey: activeHoldTargetKey(),
eligibleHoldTargetKey: holdTargetKey(),
})
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const itemElements = new Map<string, HTMLDivElement>()
@ -189,6 +212,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
let lastResetKey: string | number | undefined
let suppressAutoScrollOnce = false
let suppressHoldUntilTargetChanges = false
let suspendHeldAnchorUntilBottom = false
let lastItemsReference = props.items()
let restoreToken = 0
@ -208,6 +232,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
let programmaticScrollUntil = 0
let pendingBottomRepinAfterHold = false
let pendingContentRenderedFrame: number | null = null
let pendingBottomFollowIntentFrame: number | null = null
let bottomFollowIntentToken: string | number | null = null
let bottomFollowIntentMinItemCount = 0
let bottomFollowIntentStaleHoldTargetKey: string | null = null
let bottomFollowIntentSettleFrames = 0
let bottomFollowIntentFramesRemaining = 0
let heldAnchorKey: string | null = null
let heldAnchorElement: HTMLElement | null = null
let heldAnchorOffset: number | null = null
const state: VirtualFollowListState = {
autoScroll,
@ -218,6 +251,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
function markUserScrollIntent(direction?: "up" | "down" | null) {
if (hasActiveBottomFollowIntent()) {
clearBottomFollowIntent()
}
const now = performance.now()
scrollController.setUserIntent(direction ?? null, now + USER_SCROLL_INTENT_WINDOW_MS)
}
@ -230,8 +266,123 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
return performance.now() <= programmaticScrollUntil
}
function hasActiveBottomFollowIntent() {
return bottomFollowIntentToken !== null
}
function resetStaleHoldForBottomIntent() {
restoreToken += 1
scrollController.setRestoring(false)
setDidTriggerHoldForCurrentTarget(false)
suppressHoldUntilTargetChanges = true
pendingBottomRepinAfterHold = false
clearHeldAnchor()
}
function clearBottomFollowIntent() {
const staleTargetStillCurrent = bottomFollowIntentStaleHoldTargetKey !== null && holdTargetKey() === bottomFollowIntentStaleHoldTargetKey
bottomFollowIntentToken = null
bottomFollowIntentMinItemCount = 0
bottomFollowIntentStaleHoldTargetKey = null
bottomFollowIntentSettleFrames = 0
bottomFollowIntentFramesRemaining = 0
if (!staleTargetStillCurrent) {
suppressHoldUntilTargetChanges = false
}
}
function scheduleBottomFollowIntentFrame() {
if (!hasActiveBottomFollowIntent()) return
if (pendingBottomFollowIntentFrame !== null) return
if (typeof requestAnimationFrame !== "function") {
runBottomFollowIntentFrame()
return
}
pendingBottomFollowIntentFrame = requestAnimationFrame(() => runBottomFollowIntentFrame())
}
function runBottomFollowIntentFrame() {
pendingBottomFollowIntentFrame = null
if (!hasActiveBottomFollowIntent()) return
resetStaleHoldForBottomIntent()
dispatchFollowEvent({ type: "jump-bottom", immediate: true, explicit: true })
const minItemCountReached = props.items().length >= bottomFollowIntentMinItemCount
if (minItemCountReached) {
bottomFollowIntentSettleFrames -= 1
} else {
bottomFollowIntentSettleFrames = BOTTOM_FOLLOW_INTENT_SETTLE_FRAMES
}
bottomFollowIntentFramesRemaining -= 1
if ((minItemCountReached && bottomFollowIntentSettleFrames <= 0) || bottomFollowIntentFramesRemaining <= 0) {
clearBottomFollowIntent()
return
}
scheduleBottomFollowIntentFrame()
}
function startBottomFollowIntent(intent: VirtualFollowBottomIntent | null) {
if (!intent) return
if (!hasActiveBottomFollowIntent()) {
bottomFollowIntentStaleHoldTargetKey = activeHoldTargetKey() ?? holdTargetKey()
}
bottomFollowIntentToken = intent.token
bottomFollowIntentMinItemCount = Math.max(0, Math.floor(intent.minItemCount ?? 0))
bottomFollowIntentSettleFrames = BOTTOM_FOLLOW_INTENT_SETTLE_FRAMES
bottomFollowIntentFramesRemaining = BOTTOM_FOLLOW_INTENT_MAX_FRAMES
resetStaleHoldForBottomIntent()
runBottomFollowIntentFrame()
}
function shouldManageHeldAnchor(key = activeHoldTargetKey(), targetKey = holdTargetKey()) {
if (key === null || key !== activeHoldTargetKey()) return false
return shouldTrackHeldAnchor({
activeHoldTargetKey: key,
eligibleHoldTargetKey: targetKey,
suspendedByUser: suspendHeldAnchorUntilBottom,
})
}
function hasItemKey(key: string) {
return props.items().some((item, index) => props.getKey(item, index) === key)
}
function applyRestoredFollowSnapshot(snapshot: VirtualFollowScrollSnapshot) {
const restoredMode = restoreFollowModeFromSnapshot({
atBottom: snapshot.atBottom,
followModeType: snapshot.followModeType,
heldKey: snapshot.heldKey,
hasHeldKeyItem: snapshot.heldKey ? hasItemKey(snapshot.heldKey) : false,
})
suspendHeldAnchorUntilBottom = restoredMode.type === "holding" && Boolean(snapshot.holdAnchorSuspended)
setFollowMode(restoredMode)
scrollController.restoreMode(restoredMode)
if (restoredMode.type === "holding" && shouldManageHeldAnchor(restoredMode.key)) {
captureHeldAnchor(restoredMode.key)
return
}
clearHeldAnchor()
}
function syncControllerResult(result: ScrollControllerResult) {
const previousMode = followMode()
const previousHeldKey = previousMode.type === "holding" ? previousMode.key : null
setFollowMode(result.state.mode)
if (result.state.mode.type !== "holding") {
suspendHeldAnchorUntilBottom = false
clearHeldAnchor()
} else if (previousHeldKey !== result.state.mode.key) {
suspendHeldAnchorUntilBottom = false
clearHeldAnchor()
} else if (heldAnchorKey !== null && heldAnchorKey !== result.state.mode.key) {
clearHeldAnchor()
}
applyFollowEffect(result.effect)
}
@ -257,6 +408,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
performance.now(),
hasProgrammaticScrollIntent(),
canRejoinFollowFromDownScroll(metrics),
autoPinHoldEnabled() && streamingActive(),
)
break
}
@ -282,6 +434,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
case "hold-target-changed":
result = scrollController.holdTargetChanged(event.key, event.canPinToBottom)
break
case "clear-hold":
result = scrollController.clearHold(event.follow, event.canPinToBottom, event.suppressHold)
break
case "set-follow":
result = scrollController.setFollow(event.enabled)
break
@ -408,14 +563,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (!streamingActive()) return false
if (effectiveSuspendAutoPinToBottom()) return false
if (activeHoldTargetKey() !== null) return false
const items = props.items()
if (items.length === 0) return false
if (isAtBottom(metrics)) return true
const handle = virtuaHandle()
if (!handle) return false
const viewportEndIndex = handle.findItemIndex(metrics.offset + metrics.clientHeight - 1)
return viewportEndIndex >= Math.max(items.length - DEFAULT_REJOIN_LAST_ITEM_COUNT, 0)
return isAtBottom(metrics)
}
function getSnapshotMetrics(element: HTMLDivElement, handle?: VirtualizerHandle) {
@ -457,6 +605,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (!element) return undefined
const snapshot: VirtualFollowScrollSnapshot = getSnapshotMetrics(element, virtuaHandle())
const currentMode = hasActiveBottomFollowIntent() ? ({ type: "following" } as const) : followMode()
Object.assign(snapshot, getFollowSnapshotState(currentMode, suspendHeldAnchorUntilBottom))
if (hasActiveBottomFollowIntent()) {
snapshot.atBottom = true
delete snapshot.anchorKey
delete snapshot.anchorOffset
return snapshot
}
if (!snapshot.atBottom) {
const anchor = findTopVisibleAnchor(element)
if (anchor) {
@ -526,12 +682,19 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
return
}
if (hasActiveBottomFollowIntent()) {
scheduleBottomFollowIntentFrame()
opts?.onApplied?.()
return
}
const token = ++restoreToken
const isRestoreCurrent = () => token === restoreToken && Boolean(scrollElement())
const behavior = opts?.behavior ?? "auto"
scrollController.setRestoring(true)
const finishRestore = () => {
if (!isRestoreCurrent()) return
applyRestoredFollowSnapshot(snapshot)
scrollController.setRestoring(false)
opts?.onApplied?.()
}
@ -596,9 +759,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const element = scrollElement()
if (!handle || !element) return
const previousSnapshot = scrollController.snapshot()
const offset = getCurrentScrollOffset(element, handle)
const now = performance.now()
const programmatic = hasProgrammaticScrollIntent()
const hasFreshUserIntent = now <= previousSnapshot.userIntentUntil
const metrics = getDomMetrics(element, handle, offset)
const atBottom = isAtBottom(metrics)
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
@ -607,11 +772,29 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
setShowScrollBottomButton(hasItems && !atBottom)
setShowScrollTopButton(hasItems && !atTop)
const result = scrollController.observeViewport(metrics, now, programmatic, canRejoinFollowFromDownScroll(metrics))
const result = scrollController.observeViewport(
metrics,
now,
programmatic,
canRejoinFollowFromDownScroll(metrics),
autoPinHoldEnabled() && streamingActive(),
)
const manualHoldScroll =
previousSnapshot.mode.type === "holding" &&
result.state.mode.type === "holding" &&
hasFreshUserIntent &&
Math.abs(offset - previousSnapshot.lastObservedOffset) > 1
if (manualHoldScroll) {
suspendHeldAnchorUntilBottom = true
clearHeldAnchor()
}
if (result.state.mode.type !== followMode().type || result.effect.type !== "none") {
suppressHoldUntilTargetChanges = false
}
syncControllerResult(result)
if (result.state.mode.type === "holding" && shouldManageHeldAnchor(result.state.mode.key)) {
captureHeldAnchor(result.state.mode.key)
}
}
function performScrollToBottom(immediate = true) {
@ -707,12 +890,62 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
return props.getAnchorId ? props.getAnchorId(key) : key
}
function resolveHoldTargetElement(key: string) {
const itemWrapper = itemElements.get(key)
return resolveAutoPinHoldElement(itemWrapper, key, props.resolveAutoPinHoldElement)
}
function clearHeldAnchor() {
heldAnchorKey = null
heldAnchorElement = null
heldAnchorOffset = null
}
function captureHeldAnchor(key = activeHoldTargetKey(), target?: HTMLElement | null) {
const element = scrollElement()
if (!element || !key || !shouldManageHeldAnchor(key)) return false
const resolvedTarget = target ?? (
heldAnchorKey === key && heldAnchorElement && element.contains(heldAnchorElement)
? heldAnchorElement
: resolveHoldTargetElement(key)
)
if (!resolvedTarget || !element.contains(resolvedTarget)) return false
const containerRect = element.getBoundingClientRect()
const targetRect = resolvedTarget.getBoundingClientRect()
heldAnchorKey = key
heldAnchorElement = resolvedTarget
heldAnchorOffset = targetRect.top - containerRect.top
return true
}
function restoreHeldAnchor() {
const key = activeHoldTargetKey()
const element = scrollElement()
if (!element || !key || heldAnchorKey !== key || heldAnchorOffset === null) return false
const target = heldAnchorElement
if (!target || !element.contains(target)) {
clearHeldAnchor()
return false
}
const containerRect = element.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const currentOffset = targetRect.top - containerRect.top
const delta = currentOffset - heldAnchorOffset
if (Math.abs(delta) > 1) {
scrollToOffset((virtuaHandle()?.scrollOffset ?? element.scrollTop) + delta, false)
}
captureHeldAnchor(key, target)
return true
}
function alignHoldTarget(key: string) {
if (activeHoldTargetKey() !== key) return
if (!shouldManageHeldAnchor(key)) return
const element = scrollElement()
if (!element) return
const itemWrapper = itemElements.get(key)
if (!itemWrapper) return
const target = props.resolveAutoPinHoldElement?.(itemWrapper, key) ?? itemWrapper
const target = resolveHoldTargetElement(key)
if (!target) return
const containerRect = element.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const relativeTop = targetRect.top - containerRect.top
@ -720,24 +953,33 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (Math.abs(alignDelta) > 1) {
scrollToOffset((virtuaHandle()?.scrollOffset ?? element.scrollTop) + alignDelta, false)
}
captureHeldAnchor(key, target)
requestAnimationFrame(() => captureHeldAnchor(key, target))
}
function updateAutoPinHold() {
const element = scrollElement()
if (!element) return
if (hasActiveBottomFollowIntent()) return
const targetKey = holdTargetKey()
const heldKey = activeHoldTargetKey()
if (heldKey !== null) {
if (targetKey !== heldKey) {
suspendHeldAnchorUntilBottom = true
clearHeldAnchor()
dispatchFollowEvent({ type: "hold-target-changed", key: targetKey, canPinToBottom: !externalSuspendAutoPinToBottom() })
}
if (shouldManageHeldAnchor(heldKey, targetKey) && (heldAnchorKey !== heldKey || heldAnchorOffset === null)) {
captureHeldAnchor(heldKey)
}
return
}
if (!streamingActive()) return
if (!autoPinHoldEnabled()) return
if (!autoScroll()) return
if (externalSuspendAutoPinToBottom()) return
if (!targetKey) return
@ -745,15 +987,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (suppressHoldUntilTargetChanges) return
const itemWrapper = itemElements.get(targetKey)
if (!itemWrapper) return
const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper
const target = resolveAutoPinHoldElement(itemWrapper, targetKey, props.resolveAutoPinHoldElement)
if (!target) return
const containerRect = element.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const relativeTop = targetRect.top - containerRect.top
const exceedsViewport = targetRect.height > element.clientHeight
if (exceedsViewport && relativeTop < 0) {
if (exceedsViewport && relativeTop <= holdTargetTopThresholdPx()) {
dispatchFollowEvent({ type: "hold-candidate", key: targetKey, shouldHold: true })
setDidTriggerHoldForCurrentTarget(true)
}
@ -762,6 +1004,12 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
function flushContentRendered() {
pendingContentRenderedFrame = null
if (hasActiveBottomFollowIntent()) {
scheduleBottomFollowIntentFrame()
updateScrollButtons()
return
}
if (shouldHonorPrePinEscape() && escapeFollowIfDomMovedUp()) {
updateScrollButtons()
return
@ -770,6 +1018,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
updateAutoPinHold()
if (activeHoldTargetKey() !== null) {
if (autoScroll() && streamingActive()) pendingBottomRepinAfterHold = true
if (shouldManageHeldAnchor()) {
restoreHeldAnchor()
}
updateScrollButtons()
return
}
@ -817,18 +1068,22 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
itemElements.clear()
setDidTriggerHoldForCurrentTarget(false)
suppressHoldUntilTargetChanges = false
suspendHeldAnchorUntilBottom = false
pendingBottomRepinAfterHold = false
clearHeldAnchor()
}))
createEffect(on(holdTargetKey, (nextTargetKey, prevTargetKey) => {
if (nextTargetKey !== prevTargetKey && didTriggerHoldForCurrentTarget()) {
setDidTriggerHoldForCurrentTarget(false)
}
if (nextTargetKey !== prevTargetKey) {
if (nextTargetKey !== prevTargetKey && !hasActiveBottomFollowIntent()) {
suppressHoldUntilTargetChanges = false
}
if (activeHoldTargetKey() === null) return
if (nextTargetKey === activeHoldTargetKey()) return
suspendHeldAnchorUntilBottom = true
clearHeldAnchor()
dispatchFollowEvent({ type: "hold-target-changed", key: nextTargetKey, canPinToBottom: !externalSuspendAutoPinToBottom() })
if (pendingBottomRepinAfterHold) {
pendingBottomRepinAfterHold = false
@ -836,6 +1091,30 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
}, { defer: true }))
createEffect(on(autoPinHoldEnabled, (enabled) => {
if (enabled) return
setDidTriggerHoldForCurrentTarget(false)
suppressHoldUntilTargetChanges = false
suspendHeldAnchorUntilBottom = false
pendingBottomRepinAfterHold = false
if (activeHoldTargetKey() !== null) {
dispatchFollowEvent({
type: "clear-hold",
follow: true,
canPinToBottom: !externalSuspendAutoPinToBottom(),
suppressHold: true,
})
return
}
clearHeldAnchor()
}, { defer: true }))
createEffect(on(forceBottomFollowIntent, (intent) => {
if (!intent) return
if (intent.token === bottomFollowIntentToken) return
startBottomFollowIntent(intent)
}, { defer: true }))
// Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => {
if (pendingInitialScroll && isActive() && len > 0) {
@ -847,6 +1126,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
return
}
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
if (hasActiveBottomFollowIntent()) {
scheduleBottomFollowIntentFrame()
suppressAutoScrollOnce = false
return
}
requestAnimationFrame(() => {
dispatchFollowEvent({ type: "content-grew", canPinToBottom: autoScroll() && !effectiveSuspendAutoPinToBottom() })
})
@ -888,6 +1172,10 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
cancelAnimationFrame(pendingContentRenderedFrame)
pendingContentRenderedFrame = null
}
if (pendingBottomFollowIntentFrame !== null && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(pendingBottomFollowIntentFrame)
pendingBottomFollowIntentFrame = null
}
detachScrollIntentListeners?.()
detachScrollIntentListeners = undefined
})

View file

@ -12,6 +12,7 @@ export const appMessages = {
"app.stopInstance.title": "Instanz stoppen",
"app.stopInstance.confirmLabel": "Stoppen",
"app.stopInstance.cancelLabel": "Weiterlaufen lassen",
"app.stopInstance.toast.error": "Arbeitsbereich konnte nicht gestoppt werden.",
"emptyState.logoAlt": "CodeNomad Logo",
"emptyState.brandTitle": "CodeNomad",

View file

@ -8,7 +8,7 @@ export const instanceMessages = {
"instanceInfo.labels.folder": "Ordner",
"instanceInfo.labels.project": "Projekt",
"instanceInfo.labels.versionControl": "Versionskontrolle",
"instanceInfo.labels.opencodeVersion": "OpenCode Version",
"instanceInfo.labels.opencodeVersion": "OpenCode-Version",
"instanceInfo.labels.binaryPath": "Binärpfad",
"instanceInfo.labels.environmentVariables": "Umgebungsvariablen ({count})",
"instanceInfo.loading": "Wird geladen...",
@ -130,8 +130,8 @@ export const instanceMessages = {
"instanceShell.gitChanges.actions.stage": "Datei stagen",
"instanceShell.gitChanges.actions.unstage": "Datei unstagen",
"instanceShell.gitChanges.commit.placeholder": "Commit-Nachricht eingeben",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "Committing...",
"instanceShell.gitChanges.commit.submit": "Commit erstellen",
"instanceShell.gitChanges.commit.submitting": "Commit wird erstellt...",
"instanceShell.gitChanges.commit.success": "Commit erfolgreich erstellt",
"instanceShell.gitChanges.commit.error": "Commit konnte nicht erstellt werden",

View file

@ -20,6 +20,7 @@ export const messagingMessages = {
"messageSection.loadError.reload": "Nachrichten neu laden",
"messageSection.scroll.toFirstAriaLabel": "Zur ersten Nachricht scrollen",
"messageSection.scroll.toLatestAriaLabel": "Zur neuesten Nachricht scrollen",
"messageSection.scroll.showControlsAriaLabel": "Scroll-Steuerung anzeigen",
"messageSection.scroll.enableHoldAriaLabel": "Halten für lange Assistentenantworten aktivieren",
"messageSection.scroll.disableHoldAriaLabel": "Halten für lange Assistentenantworten deaktivieren",
"messageSection.quote.addAsQuote": "Als Zitat hinzufügen",
@ -78,6 +79,15 @@ export const messagingMessages = {
"messageBlock.reasoning.indicator.hide": "Verbergen",
"messageBlock.reasoning.indicator.view": "Ansehen",
"messageBlock.reasoning.detailsAriaLabel": "Denk-Details",
"messageBlock.reasoning.thoughtsFallback": "Gedanken",
"messageBlock.reasoning.thoughtFor.seconds.one": "{count} Sekunde nachgedacht",
"messageBlock.reasoning.thoughtFor.seconds.other": "{count} Sekunden nachgedacht",
"messageBlock.reasoning.thoughtFor.minutes.one": "{count} Minute nachgedacht",
"messageBlock.reasoning.thoughtFor.minutes.other": "{count} Minuten nachgedacht",
"messageBlock.reasoning.thoughtFor.hours.one": "{count} Stunde nachgedacht",
"messageBlock.reasoning.thoughtFor.hours.other": "{count} Stunden nachgedacht",
"messageBlock.reasoning.copyTitle": "Gedanken kopieren",
"messageBlock.reasoning.copyAriaLabel": "Gedanken kopieren",
"codeBlockInline.actions.copy": "Kopieren",
"codeBlockInline.actions.copied": "Kopiert!",
@ -106,6 +116,14 @@ export const messagingMessages = {
"messageItem.actions.deleteMessageFailedMessage": "Nachricht konnte nicht gelöscht werden",
"messageItem.selection.checkboxAriaLabel": "Nachricht zum Löschen auswählen",
"messageItem.selection.selectForDeletion": "Nachricht zum Löschen auswählen",
"messageItem.selection.deselectForDeletion": "Nachricht zum Löschen abwählen",
"messageItem.duration.workedFor.seconds.one": "Hat {count} Sekunde gearbeitet",
"messageItem.duration.workedFor.seconds.other": "Hat {count} Sekunden gearbeitet",
"messageItem.duration.workedFor.minutes.one": "Hat {count} Minute gearbeitet",
"messageItem.duration.workedFor.minutes.other": "Hat {count} Minuten gearbeitet",
"messageItem.duration.workedFor.hours.one": "Hat {count} Stunde gearbeitet",
"messageItem.duration.workedFor.hours.other": "Hat {count} Stunden gearbeitet",
"messageSection.bulkDelete.toolbarAriaLabel": "Ausgewählte Elemente ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Ausgewählte Elemente löschen",
@ -129,6 +147,10 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "Dieses Element löschen",
"messagePart.actions.deleteFailedTitle": "Löschen fehlgeschlagen",
"messagePart.actions.deleteFailedMessage": "Element konnte nicht gelöscht werden",
"messagePart.pastedText.summary": "Eingefügter Text",
"messagePart.pastedText.lines.one": "{count} Zeile",
"messagePart.pastedText.lines.other": "{count} Zeilen",
"messagePart.pastedText.copyAriaLabel": "Eingefügten Textblock kopieren",
"messageItem.attachment.defaultName": "Anhang",
"messageItem.attachment.downloadAriaLabel": "{name} herunterladen",
"messageItem.agentMeta.agentLabel": "Agent: {agent}",

View file

@ -131,7 +131,7 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.debug": "Debug",
"settings.opencode.logLevel.option.info": "Info",
"settings.opencode.logLevel.option.warn": "Warn",
"settings.opencode.logLevel.option.error": "Error",
"settings.opencode.logLevel.option.error": "Fehler",
"settings.configFiles.title": "Konfigurationsdateien",
"settings.configFiles.subtitle": "Bearbeiten Sie freigegebene globale Konfigurationsdateien auf diesem Server.",
"settings.configFiles.selector.label": "Datei",
@ -210,6 +210,23 @@ export const settingsMessages = {
"settings.appearance.behavior.title": "Interaktion",
"settings.appearance.behavior.subtitle": "Standardwerte für Nachrichten, Diffs und Eingaben.",
"settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets",
"settings.behavior.expansionPreset.minimal.title": "Minimal",
"settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.",
"settings.behavior.expansionPreset.balanced.title": "Balanced",
"settings.behavior.expansionPreset.balanced.description": "Show important actions while keeping noisy reads and fetches compact.",
"settings.behavior.expansionPreset.detailed.title": "Detailed",
"settings.behavior.expansionPreset.detailed.description": "Open most tool and thinking output for a fuller transcript.",
"settings.behavior.expansionPreset.everything.title": "Everything",
"settings.behavior.expansionPreset.everything.description": "Expand all tool and thinking output by default.",
"settings.behavior.expansionPreset.custom.title": "Custom",
"settings.behavior.expansionPreset.custom.description": "Your customized transcript detail defaults.",
"settings.behavior.expansionDefaults.title": "Expansion defaults",
"settings.behavior.expansionDefaults.itemColumn": "Item",
"settings.behavior.expansionDefaults.stateColumn": "Default state",
"settings.behavior.expansionDefaults.thinking": "thinking",
"settings.behavior.expansionDefaults.otherTools": "Other tools",
"settings.behavior.expansionDefaults.rowAriaLabel": "Default state for {item}",
"settings.behavior.keyboardHints.title": "Tastenkombinations-Hinweise",
"settings.behavior.keyboardHints.subtitle": "Tastenkombinations-Hinweise in der UI anzeigen.",
"settings.behavior.thinking.title": "Denkabschnitte",
@ -236,6 +253,8 @@ export const settingsMessages = {
"settings.behavior.autoCleanup.subtitle": "Leere Sitzungen beim Erstellen neuer Sitzungen automatisch bereinigen.",
"settings.behavior.keepUnseenSubagentIdle.title": "Statusanzeigen von Unteragenten beibehalten",
"settings.behavior.keepUnseenSubagentIdle.subtitle": "Statusanzeigen von Unteragenten sichtbar lassen, bis sie angesehen wurden.",
"settings.behavior.tauriNativeEventTransport.title": "Nativer Tauri-Ereignistransport",
"settings.behavior.tauriNativeEventTransport.subtitle": "Den Rust-nativen Desktop-Ereignistransport in Tauri verwenden. Deaktivieren, um auf den Browser-EventSource-Pfad zurückzufallen.",
"settings.behavior.promptVoiceInput.title": "Prompt-Spracheingabe",
"settings.behavior.promptVoiceInput.subtitle": "Mikrofon-Steuerung für Sprache-zu-Text anzeigen, wenn Sprache konfiguriert ist.",
"settings.behavior.promptSubmit.title": "Enter zum Absenden",
@ -265,14 +284,14 @@ export const settingsMessages = {
"settings.speech.ttsVoice.title": "Standardstimme",
"settings.speech.ttsVoice.subtitle": "Standardstimme für Text-zu-Sprache.",
"settings.speech.playbackMode.title": "Wiedergabemodus",
"settings.speech.playbackMode.subtitle": "Wählen Sie, ob die Wiedergabe sofort (Streaming) oder nach der Generierung (Buffered) startet.",
"settings.speech.playbackMode.subtitle": "Wählen Sie, ob die Wiedergabe sofort (Streaming) oder nach der Generierung (gepuffert) startet.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.playbackMode.buffered": "Gepuffert",
"settings.speech.ttsFormat.title": "Ausgabeformat",
"settings.speech.ttsFormat.subtitle": "Wählen Sie das Audioformat für die synthetisierte Sprache.",
"settings.speech.help": "Die Spracheingabe erscheint, wenn die Transkription konfiguriert ist. Die Wiedergabe verwendet den gewählten TTS-Modus.",
"settings.speech.compatibility.streamingUnavailable": "Ihr Anbieter unterstützt kein Streaming-TTS. Schalten Sie auf 'Buffered' um.",
"settings.speech.compatibility.browserStreamingUnavailable": "Ihr Browser unterstützt kein Streaming für dieses Format. Wählen Sie 'Buffered' oder ein anderes Format.",
"settings.speech.compatibility.streamingUnavailable": "Ihr Anbieter unterstützt kein Streaming-TTS. Schalten Sie auf 'Gepuffert' um.",
"settings.speech.compatibility.browserStreamingUnavailable": "Ihr Browser unterstützt kein Streaming für dieses Format. Wählen Sie 'Gepuffert' oder ein anderes Format.",
"settings.speech.compatibility.runtimeNote": "Alle Formate bleiben im Streaming-Modus auswählbar. Manche Browser-Anbieter-Kombinationen können bei der Wiedergabe dennoch fehlschlagen.",
"settings.speech.testPlayback.action": "Wiedergabe testen",
"settings.speech.testPlayback.generating": "Beispiel wird generiert",

View file

@ -4,6 +4,8 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Tool-Aufruf-Titel kopieren",
"toolCall.header.copyAriaLabel": "Tool-Aufruf-Titel kopieren",
"toolCall.header.copyOutputTitle": "Tool-Ausgabe kopieren",
"toolCall.header.copyOutputAriaLabel": "Tool-Ausgabe kopieren",
"toolCall.header.showInputTitle": "Tool-Argumente anzeigen",
"toolCall.header.showInputAriaLabel": "Tool-Argumente anzeigen",
@ -12,6 +14,10 @@ export const toolCallMessages = {
"toolCall.io.input": "Tool-Eingabe",
"toolCall.io.output": "Tool-Ausgabe",
"toolCall.io.copyInputTitle": "Tool-Eingabe kopieren",
"toolCall.io.copyInputAriaLabel": "Tool-Eingabe kopieren",
"toolCall.io.copyOutputTitle": "Tool-Ausgabe kopieren",
"toolCall.io.copyOutputAriaLabel": "Tool-Ausgabe kopieren",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
@ -53,7 +59,7 @@ export const toolCallMessages = {
"toolCall.renderer.read.detail.offset": "Offset: {offset}",
"toolCall.renderer.read.detail.limit": "Limit: {limit}",
"toolCall.renderer.todo.empty": "Noch keine Planelemente.",
"toolCall.renderer.todo.empty": "Keine Elemente geplant",
"toolCall.renderer.todo.status.pending": "Ausstehend",
"toolCall.renderer.todo.status.inProgress": "In Arbeit",
"toolCall.renderer.todo.status.completed": "Abgeschlossen",
@ -71,13 +77,9 @@ export const toolCallMessages = {
"toolCall.permission.actions.allowOnce": "Einmalig erlauben",
"toolCall.permission.actions.alwaysAllow": "Immer erlauben",
"toolCall.permission.actions.deny": "Ablehnen",
"toolCall.permission.actions.confirmDeny": "Ablehnung bestätigen",
"toolCall.permission.actions.cancel": "Abbrechen",
"toolCall.permission.shortcuts.allowOnce": "Einmalig erlauben",
"toolCall.permission.shortcuts.alwaysAllow": "Immer erlauben",
"toolCall.permission.rejectReason.label": "Warum diese Berechtigung ablehnen?",
"toolCall.permission.rejectReason.placeholder": "Sagen Sie CodeNomad, was anders gemacht werden soll...",
"toolCall.permission.rejectReason.hint": "Der Agent sieht dieses Feedback und kann seine nächste Aktion anpassen.",
"toolCall.permission.rejectReason.placeholder": "Optionales Feedback beim Ablehnen, z. B. einen sichereren Befehl verwenden...",
"toolCall.permission.errors.unableToUpdate": "Berechtigung konnte nicht aktualisiert werden",
"permissionApproval.title": "Anfragen",
@ -94,11 +96,7 @@ export const toolCallMessages = {
"permissionApproval.actions.allowOnce": "Einmalig erlauben",
"permissionApproval.actions.alwaysAllow": "Immer erlauben",
"permissionApproval.actions.deny": "Ablehnen",
"permissionApproval.actions.confirmDeny": "Ablehnung bestätigen",
"permissionApproval.actions.cancel": "Abbrechen",
"permissionApproval.rejectReason.label": "Warum diese Berechtigung ablehnen?",
"permissionApproval.rejectReason.placeholder": "Sagen Sie CodeNomad, was anders gemacht werden soll...",
"permissionApproval.rejectReason.hint": "Der Agent sieht dieses Feedback und kann seine nächste Aktion anpassen.",
"permissionApproval.rejectReason.placeholder": "Optionales Feedback beim Ablehnen, z. B. einen sichereren Befehl verwenden...",
"permissionApproval.fallbackHint": "Sitzung für weitere Informationen laden.",
"permissionApproval.errors.unableToUpdatePermission": "Berechtigung konnte nicht aktualisiert werden",

View file

@ -12,6 +12,7 @@ export const appMessages = {
"app.stopInstance.title": "Stop instance",
"app.stopInstance.confirmLabel": "Stop",
"app.stopInstance.cancelLabel": "Keep running",
"app.stopInstance.toast.error": "Failed to stop workspace.",
"emptyState.logoAlt": "CodeNomad logo",
"emptyState.brandTitle": "CodeNomad",

View file

@ -20,6 +20,7 @@ export const messagingMessages = {
"messageSection.loadError.reload": "Reload messages",
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
"messageSection.scroll.showControlsAriaLabel": "Show scroll controls",
"messageSection.scroll.enableHoldAriaLabel": "Enable hold for long assistant replies",
"messageSection.scroll.disableHoldAriaLabel": "Disable hold for long assistant replies",
"messageSection.quote.addAsQuote": "Add as quote",
@ -78,6 +79,15 @@ export const messagingMessages = {
"messageBlock.reasoning.indicator.hide": "Hide",
"messageBlock.reasoning.indicator.view": "View",
"messageBlock.reasoning.detailsAriaLabel": "Reasoning details",
"messageBlock.reasoning.thoughtsFallback": "Thoughts",
"messageBlock.reasoning.thoughtFor.seconds.one": "Thought for {count} second",
"messageBlock.reasoning.thoughtFor.seconds.other": "Thought for {count} seconds",
"messageBlock.reasoning.thoughtFor.minutes.one": "Thought for {count} minute",
"messageBlock.reasoning.thoughtFor.minutes.other": "Thought for {count} minutes",
"messageBlock.reasoning.thoughtFor.hours.one": "Thought for {count} hour",
"messageBlock.reasoning.thoughtFor.hours.other": "Thought for {count} hours",
"messageBlock.reasoning.copyTitle": "Copy thoughts",
"messageBlock.reasoning.copyAriaLabel": "Copy thoughts",
"codeBlockInline.actions.copy": "Copy",
"codeBlockInline.actions.copied": "Copied!",
@ -106,6 +116,14 @@ export const messagingMessages = {
"messageItem.actions.deleteMessageFailedMessage": "Failed to delete message",
"messageItem.selection.checkboxAriaLabel": "Select message for deletion",
"messageItem.selection.selectForDeletion": "Select message for deletion",
"messageItem.selection.deselectForDeletion": "Deselect message for deletion",
"messageItem.duration.workedFor.seconds.one": "Worked for {count} second",
"messageItem.duration.workedFor.seconds.other": "Worked for {count} seconds",
"messageItem.duration.workedFor.minutes.one": "Worked for {count} minute",
"messageItem.duration.workedFor.minutes.other": "Worked for {count} minutes",
"messageItem.duration.workedFor.hours.one": "Worked for {count} hour",
"messageItem.duration.workedFor.hours.other": "Worked for {count} hours",
"messageSection.bulkDelete.toolbarAriaLabel": "Selected items ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Delete selected items",

View file

@ -210,6 +210,23 @@ export const settingsMessages = {
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
"settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets",
"settings.behavior.expansionPreset.minimal.title": "Minimal",
"settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.",
"settings.behavior.expansionPreset.balanced.title": "Balanced",
"settings.behavior.expansionPreset.balanced.description": "Show important actions while keeping noisy reads and fetches compact.",
"settings.behavior.expansionPreset.detailed.title": "Detailed",
"settings.behavior.expansionPreset.detailed.description": "Open most tool and thinking output for a fuller transcript.",
"settings.behavior.expansionPreset.everything.title": "Everything",
"settings.behavior.expansionPreset.everything.description": "Expand all tool and thinking output by default.",
"settings.behavior.expansionPreset.custom.title": "Custom",
"settings.behavior.expansionPreset.custom.description": "Your customized transcript detail defaults.",
"settings.behavior.expansionDefaults.title": "Expansion defaults",
"settings.behavior.expansionDefaults.itemColumn": "Item",
"settings.behavior.expansionDefaults.stateColumn": "Default state",
"settings.behavior.expansionDefaults.thinking": "thinking",
"settings.behavior.expansionDefaults.otherTools": "Other tools",
"settings.behavior.expansionDefaults.rowAriaLabel": "Default state for {item}",
"settings.behavior.keyboardHints.title": "Keyboard shortcut hints",
"settings.behavior.keyboardHints.subtitle": "Show keyboard shortcut hints across the UI.",
"settings.behavior.thinking.title": "Thinking sections",

View file

@ -4,6 +4,8 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.copyOutputTitle": "Copy tool output",
"toolCall.header.copyOutputAriaLabel": "Copy tool output",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
@ -12,6 +14,10 @@ export const toolCallMessages = {
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.io.copyInputTitle": "Copy tool input",
"toolCall.io.copyInputAriaLabel": "Copy tool input",
"toolCall.io.copyOutputTitle": "Copy tool output",
"toolCall.io.copyOutputAriaLabel": "Copy tool output",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
@ -53,7 +59,7 @@ export const toolCallMessages = {
"toolCall.renderer.read.detail.offset": "Offset: {offset}",
"toolCall.renderer.read.detail.limit": "Limit: {limit}",
"toolCall.renderer.todo.empty": "No plan items yet.",
"toolCall.renderer.todo.empty": "No items planned",
"toolCall.renderer.todo.status.pending": "Pending",
"toolCall.renderer.todo.status.inProgress": "In progress",
"toolCall.renderer.todo.status.completed": "Completed",
@ -71,13 +77,9 @@ export const toolCallMessages = {
"toolCall.permission.actions.allowOnce": "Allow Once",
"toolCall.permission.actions.alwaysAllow": "Always Allow",
"toolCall.permission.actions.deny": "Deny",
"toolCall.permission.actions.confirmDeny": "Confirm Deny",
"toolCall.permission.actions.cancel": "Cancel",
"toolCall.permission.shortcuts.allowOnce": "Allow once",
"toolCall.permission.shortcuts.alwaysAllow": "Always allow",
"toolCall.permission.rejectReason.label": "Why deny this permission?",
"toolCall.permission.rejectReason.placeholder": "Tell CodeNomad what to do differently...",
"toolCall.permission.rejectReason.hint": "The agent will see this feedback and can adjust its next action.",
"toolCall.permission.rejectReason.placeholder": "Optional feedback for Deny, e.g. use a safer command...",
"toolCall.permission.errors.unableToUpdate": "Unable to update permission",
"permissionApproval.title": "Requests",
@ -94,11 +96,7 @@ export const toolCallMessages = {
"permissionApproval.actions.allowOnce": "Allow Once",
"permissionApproval.actions.alwaysAllow": "Always Allow",
"permissionApproval.actions.deny": "Deny",
"permissionApproval.actions.confirmDeny": "Confirm Deny",
"permissionApproval.actions.cancel": "Cancel",
"permissionApproval.rejectReason.label": "Why deny this permission?",
"permissionApproval.rejectReason.placeholder": "Tell CodeNomad what to do differently...",
"permissionApproval.rejectReason.hint": "The agent will see this feedback and can adjust its next action.",
"permissionApproval.rejectReason.placeholder": "Optional feedback for Deny, e.g. use a safer command...",
"permissionApproval.fallbackHint": "Load session for more information.",
"permissionApproval.errors.unableToUpdatePermission": "Unable to update permission",

View file

@ -12,6 +12,7 @@ export const appMessages = {
"app.stopInstance.title": "Detener instancia",
"app.stopInstance.confirmLabel": "Detener",
"app.stopInstance.cancelLabel": "Seguir ejecutándose",
"app.stopInstance.toast.error": "No se pudo detener el workspace.",
"emptyState.logoAlt": "Logo de CodeNomad",
"emptyState.brandTitle": "CodeNomad",
@ -33,4 +34,10 @@ export const appMessages = {
"releases.devUpdateAvailable.title": "Compilación dev disponible",
"releases.devUpdateAvailable.message": "Hay una nueva compilación dev disponible: {version}.",
"releases.devUpdateAvailable.action": "Ver release",
"theme.mode.system": "Sistema",
"theme.mode.light": "Claro",
"theme.mode.dark": "Oscuro",
"theme.toggle.title": "Tema: {mode}",
"theme.toggle.ariaLabel": "Tema: {mode}",
} as const

View file

@ -168,6 +168,7 @@ export const commandMessages = {
"unifiedPicker.sections.commands": "COMANDOS",
"unifiedPicker.sections.agents": "AGENTES",
"unifiedPicker.sections.files": "ARCHIVOS",
"unifiedPicker.sections.workspaceRoot": "RAÍZ DEL WORKSPACE",
"unifiedPicker.badge.subagent": "subagente",
"unifiedPicker.footer.navigate": "navegar",
"unifiedPicker.footer.select": "seleccionar",

View file

@ -2,7 +2,7 @@ export const folderSelectionMessages = {
"folderSelection.language.ariaLabel": "Idioma",
"folderSelection.logoAlt": "Logo de CodeNomad",
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con AI",
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con IA",
"folderSelection.links.github": "GitHub de CodeNomad",
"folderSelection.links.githubStars": "Estrellas de GitHub de CodeNomad",
@ -91,11 +91,11 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
"folderSelection.servers.certificateInstall.title": "Instalar certificado local",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el trafico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado despues de esto.",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el tráfico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado después de esto.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continuar",
"folderSelection.servers.certificateInstall.cancelLabel": "Cancelar",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.",
"folderSelection.sidecars.button": "Open SideCar",
"folderSelection.sidecars.button": "Abrir SideCar",
"projectRenameDialog.title": "Renombrar workspace",
"projectRenameDialog.description.withLabel": "Actualiza el nombre del workspace para \"{label}\".",

View file

@ -119,6 +119,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins que personalizan el comportamiento de la UI y del servidor, y añaden funciones más allá de MCP y LSP.",
"instanceShell.gitChanges.noSessionSelected": "Selecciona una sesión para ver los cambios de Git.",
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
"instanceShell.gitChanges.deleted": "Eliminado",
@ -141,6 +142,8 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerPlaceholder": "La vista detallada se agregará en el siguiente paso.",
"instanceShell.filesShell.viewerEmpty": "Ningún archivo seleccionado.",
"instanceShell.filesShell.listEmpty": "No hay archivos en esta carpeta.",
"instanceShell.filesShell.hideFiles": "Ocultar archivos",
"instanceShell.filesShell.showFiles": "Mostrar archivos",
"instanceShell.filesShell.search.placeholder": "Filtrar archivos de esta carpeta",
"instanceShell.filesShell.search.ariaLabel": "Filtrar archivos de esta carpeta",
"instanceShell.filesShell.search.empty": "No hay archivos coincidentes.",
@ -151,6 +154,13 @@ export const instanceMessages = {
"instanceShell.filesShell.showSource": "Mostrar fuente",
"instanceShell.filesShell.enableWordWrap": "Activar ajuste de línea",
"instanceShell.filesShell.disableWordWrap": "Desactivar ajuste de línea",
"instanceShell.diff.hideUnchanged": "Ocultar regiones sin cambios",
"instanceShell.diff.showFull": "Mostrar archivo completo",
"instanceShell.diff.switchToSplit": "Cambiar a vista dividida",
"instanceShell.diff.switchToUnified": "Cambiar a vista unificada",
"instanceShell.diff.enableWordWrap": "Activar ajuste de línea",
"instanceShell.diff.disableWordWrap": "Desactivar ajuste de línea",
"instanceShell.worktree.create": "+ Crear worktree",
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
"instanceShell.plan.empty": "Aún no hay nada planificado.",

View file

@ -20,6 +20,7 @@ export const messagingMessages = {
"messageSection.loadError.reload": "Recargar mensajes",
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
"messageSection.scroll.showControlsAriaLabel": "Mostrar controles de desplazamiento",
"messageSection.scroll.enableHoldAriaLabel": "Activar pausa para respuestas largas del asistente",
"messageSection.scroll.disableHoldAriaLabel": "Desactivar pausa para respuestas largas del asistente",
"messageSection.quote.addAsQuote": "Añadir como cita",
@ -80,6 +81,15 @@ export const messagingMessages = {
"messageBlock.reasoning.indicator.hide": "Ocultar",
"messageBlock.reasoning.indicator.view": "Ver",
"messageBlock.reasoning.detailsAriaLabel": "Detalles del razonamiento",
"messageBlock.reasoning.thoughtsFallback": "Pensamientos",
"messageBlock.reasoning.thoughtFor.seconds.one": "Pensó durante {count} segundo",
"messageBlock.reasoning.thoughtFor.seconds.other": "Pensó durante {count} segundos",
"messageBlock.reasoning.thoughtFor.minutes.one": "Pensó durante {count} minuto",
"messageBlock.reasoning.thoughtFor.minutes.other": "Pensó durante {count} minutos",
"messageBlock.reasoning.thoughtFor.hours.one": "Pensó durante {count} hora",
"messageBlock.reasoning.thoughtFor.hours.other": "Pensó durante {count} horas",
"messageBlock.reasoning.copyTitle": "Copiar pensamientos",
"messageBlock.reasoning.copyAriaLabel": "Copiar pensamientos",
"codeBlockInline.actions.copy": "Copiar",
"codeBlockInline.actions.copied": "¡Copiado!",
@ -88,8 +98,8 @@ export const messagingMessages = {
"messageItem.speaker.assistant": "Asistente",
"messageItem.actions.revert": "Revertir",
"messageItem.actions.revertTitle": "Deshacer cambios hasta aqui (elimina mensajes)",
"messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork desde este mensaje",
"messageItem.actions.fork": "Bifurcar",
"messageItem.actions.forkTitle": "Bifurcar desde este mensaje",
"messageItem.actions.copy": "Copiar",
"messageItem.actions.copyTitle": "Copiar mensaje",
"messageItem.actions.copied": "¡Copiado!",
@ -108,6 +118,14 @@ export const messagingMessages = {
"messageItem.actions.deleteMessageFailedMessage": "No se pudo eliminar el mensaje",
"messageItem.selection.checkboxAriaLabel": "Seleccionar mensaje para eliminar",
"messageItem.selection.selectForDeletion": "Seleccionar mensaje para eliminar",
"messageItem.selection.deselectForDeletion": "Anular selección del mensaje para eliminar",
"messageItem.duration.workedFor.seconds.one": "Trabajó durante {count} segundo",
"messageItem.duration.workedFor.seconds.other": "Trabajó durante {count} segundos",
"messageItem.duration.workedFor.minutes.one": "Trabajó durante {count} minuto",
"messageItem.duration.workedFor.minutes.other": "Trabajó durante {count} minutos",
"messageItem.duration.workedFor.hours.one": "Trabajó durante {count} hora",
"messageItem.duration.workedFor.hours.other": "Trabajó durante {count} horas",
"messageSection.bulkDelete.toolbarAriaLabel": "Elementos seleccionados ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Eliminar elementos seleccionados",

View file

@ -32,7 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "Mostrar todos los modelos",
"modelSelector.favorite.add": "Agregar a favoritos",
"modelSelector.favorite.remove": "Quitar de favoritos",
"modelSelector.manageProviders": "Manage Providers",
"modelSelector.manageProviders": "Gestionar proveedores",
"thinkingSelector.variant.default": "Por defecto",
"thinkingSelector.label": "Pensamiento: {variant}",
@ -64,66 +64,66 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "Disp.",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.providers": "Providers",
"settings.title": "Configuración",
"settings.navigationAriaLabel": "Secciones de configuración",
"settings.close": "Cerrar configuración",
"settings.content.eyebrow": "Preferencias del workspace",
"settings.open.title": "Abrir configuración",
"settings.open.ariaLabel": "Abrir configuración",
"settings.nav.appearance": "Apariencia",
"settings.nav.notifications": "Notificaciones",
"settings.nav.remote": "Acceso remoto",
"settings.nav.speech": "Voz",
"settings.nav.providers": "Proveedores",
"settings.nav.opencode": "OpenCode",
"settings.nav.configFiles": "Config Files",
"settings.nav.configFiles": "Archivos de configuración",
"settings.nav.info": "Info",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.scope.device": "Este dispositivo",
"settings.scope.server": "Configuración del servidor",
"settings.common.enabled": "Activado",
"settings.common.disabled": "Desactivado",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.appearance.title": "Apariencia",
"settings.section.appearance.subtitle": "Ajusta cómo se ve la app en este dispositivo.",
"settings.appearance.theme.title": "Tema",
"settings.appearance.theme.subtitle": "Elige el modo de color usado en toda la app.",
"settings.appearance.theme.option.system": "Coincidir con la configuración del sistema operativo",
"settings.appearance.theme.option.light": "Usar apariencia clara",
"settings.appearance.theme.option.dark": "Usar apariencia oscura",
"settings.section.notifications.title": "Notificaciones",
"settings.section.notifications.subtitle": "Controla las notificaciones del sistema para la actividad de sesiones.",
"settings.notifications.permission.granted": "Concedido",
"settings.notifications.permission.denied": "Denegado",
"settings.notifications.permission.default": "No concedido",
"settings.notifications.permission.unsupported": "No compatible",
"settings.notifications.messages.unsupportedEnvironment": "Las notificaciones del sistema no son compatibles en este entorno.",
"settings.notifications.messages.permissionDenied": "Permiso de notificaciones denegado. Activa las notificaciones en la configuración del sistema o del navegador.",
"settings.notifications.messages.permissionNotGranted": "Permiso de notificaciones no concedido.",
"settings.notifications.messages.unsupportedGeneral": "Las notificaciones no son compatibles en este entorno.",
"settings.notifications.messages.permissionGranted": "Permiso concedido. Ahora puedes activar las notificaciones.",
"settings.notifications.messages.permissionRequestDenied": "Permiso denegado. Es posible que debas activar las notificaciones en la configuración del sistema o del navegador.",
"settings.notifications.sessionStatus.title": "Notificaciones de estado de sesión",
"settings.notifications.sessionStatus.subtitle": "Recibe alertas cuando las sesiones necesiten tu atención.",
"settings.notifications.enable.title": "Activar notificaciones",
"settings.notifications.enable.permission": "Permiso: {permission}",
"settings.notifications.requestPermission.title": "Solicitar permiso",
"settings.notifications.requestPermission.subtitle": "Permite que la app envíe notificaciones en este dispositivo.",
"settings.notifications.requestPermission.action": "Solicitar",
"settings.notifications.allowVisible.title": "Notificar cuando la app tenga el foco",
"settings.notifications.allowVisible.subtitle": "Mantén las alertas activadas incluso cuando esta ventana esté visible.",
"settings.notifications.unsupportedNote": "Las notificaciones no son compatibles en este entorno. El control de notificaciones permanece desactivado.",
"settings.notifications.events.title": "Notificarme cuando",
"settings.notifications.events.subtitle": "Elige qué eventos de sesión deben enviar alertas.",
"settings.notifications.events.needsInput": "La sesión necesita entrada",
"settings.notifications.events.idle": "La sesión queda inactiva",
"settings.notifications.status.enabled": "Notificaciones activadas",
"settings.notifications.status.disabled": "Notificaciones desactivadas",
"settings.notifications.status.unsupported": "Notificaciones no compatibles",
"settings.section.remote.title": "Acceso remoto",
"settings.section.remote.subtitle": "Revisa cómo se expone este servidor en tu red y protege las credenciales de acceso.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.section.opencode.subtitle": "Elige el binario de OpenCode y el entorno usados para nuevas instancias.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.runtime.subtitle": "Configura con qué binario de OpenCode se inician las nuevas instancias.",
"settings.opencode.logLevel.title": "Nivel de logs de OpenCode",
"settings.opencode.logLevel.subtitle": "Define el nivel de logs usado al iniciar nuevas instancias de OpenCode.",
"settings.opencode.logLevel.selector.title": "Verbosidad de logs",
@ -132,131 +132,148 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "Informacion",
"settings.opencode.logLevel.option.warn": "Advertencia",
"settings.opencode.logLevel.option.error": "Error",
"settings.configFiles.title": "Config Files",
"settings.configFiles.subtitle": "Edit allowlisted global configuration files on this server.",
"settings.configFiles.selector.label": "File",
"settings.configFiles.selector.placeholder": "Select config file",
"settings.configFiles.actions.save": "Save",
"settings.configFiles.actions.reload": "Reload",
"settings.configFiles.state.unsaved": "Unsaved changes",
"settings.configFiles.state.notCreated": "File will be created on save",
"settings.configFiles.empty": "No config file selected.",
"settings.configFiles.errors.loadList": "Failed to load config files.",
"settings.configFiles.errors.loadContent": "Failed to load config file.",
"settings.configFiles.errors.save": "Failed to save config file.",
"settings.configFiles.toast.saveSuccess": "Config file saved.",
"settings.configFiles.toast.saveError": "Failed to save config file.",
"settings.configFiles.confirmDiscard.message": "Discard unsaved changes before continuing?",
"settings.configFiles.confirmDiscard.confirmLabel": "Discard changes",
"settings.configFiles.confirmDiscard.cancelLabel": "Keep editing",
"settings.configFiles.title": "Archivos de configuración",
"settings.configFiles.subtitle": "Edita archivos de configuración global permitidos en este servidor.",
"settings.configFiles.selector.label": "Archivo",
"settings.configFiles.selector.placeholder": "Seleccionar archivo de configuración",
"settings.configFiles.actions.save": "Guardar",
"settings.configFiles.actions.reload": "Recargar",
"settings.configFiles.state.unsaved": "Cambios sin guardar",
"settings.configFiles.state.notCreated": "El archivo se creará al guardar",
"settings.configFiles.empty": "No hay ningún archivo de configuración seleccionado.",
"settings.configFiles.errors.loadList": "No se pudieron cargar los archivos de configuración.",
"settings.configFiles.errors.loadContent": "No se pudo cargar el archivo de configuración.",
"settings.configFiles.errors.save": "No se pudo guardar el archivo de configuración.",
"settings.configFiles.toast.saveSuccess": "Archivo de configuración guardado.",
"settings.configFiles.toast.saveError": "No se pudo guardar el archivo de configuración.",
"settings.configFiles.confirmDiscard.message": "¿Descartar los cambios sin guardar antes de continuar?",
"settings.configFiles.confirmDiscard.confirmLabel": "Descartar cambios",
"settings.configFiles.confirmDiscard.cancelLabel": "Seguir editando",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.title": "Autenticación de proveedores",
"settings.providers.subtitle": "Conecta claves API o cuentas OAuth para la instancia activa de OpenCode.",
"settings.providers.refresh": "Actualizar",
"settings.providers.selectProvider": "Seleccionar proveedor",
"settings.providers.available.title": "Proveedores disponibles",
"settings.providers.configured.title": "Proveedores configurados",
"settings.providers.loading": "Cargando métodos de autenticación de proveedores...",
"settings.providers.empty.noInstance": "Inicia un workspace de OpenCode antes de gestionar la autenticación de proveedores.",
"settings.providers.empty.noProviders": "No hay proveedores disponibles desde esta instancia de OpenCode.",
"settings.providers.empty.noConfiguredProviders": "No hay proveedores configurados actualmente para esta instancia de OpenCode.",
"settings.providers.models.one": "{count} modelo",
"settings.providers.models.other": "{count} modelos",
"settings.providers.actions.connect": "Conectar",
"settings.providers.actions.disconnect": "Desconectar",
"settings.providers.actions.remove": "Quitar autenticación guardada",
"settings.providers.actions.close": "Cerrar",
"settings.providers.actions.continue": "Continuar",
"settings.providers.actions.working": "Trabajando...",
"settings.providers.method.title": "Método de autenticación",
"settings.providers.method.subtitle": "Elige el flujo que OpenCode expone para este proveedor.",
"settings.providers.method.api": "Clave API",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.providers.method.mixed": "Clave API y OAuth",
"settings.providers.method.fallback": "Clave API",
"settings.providers.status.available": "Disponible",
"settings.providers.auth.title": "Conectar {provider}",
"settings.providers.auth.subtitle": "La autenticación se guarda dentro del servidor activo de OpenCode.",
"settings.providers.apiKey.label": "Clave API",
"settings.providers.apiKey.placeholder": "Pega la clave API del proveedor",
"settings.providers.oauth.noPrompts": "Este método OAuth no necesita detalles adicionales antes de abrir el navegador.",
"settings.providers.oauth.promptsUnsupported": "Este método OAuth requiere entradas específicas del proveedor que la build actual del SDK no admite.",
"settings.providers.oauth.enterCode": "Completa el flujo del navegador y luego pega el código de autorización.",
"settings.providers.oauth.openPage": "Abrir página de autorización",
"settings.providers.oauth.openHere": "Abrir en esta pestaña",
"settings.providers.oauth.copyLink": "Copiar enlace",
"settings.providers.oauth.linkCopied": "Enlace copiado",
"settings.providers.oauth.popupBlocked": "Es posible que tu navegador haya bloqueado la página de autorización. Usa las acciones de arriba para continuar el inicio de sesión.",
"settings.providers.oauth.popup.loadingTitle": "Autorización de OpenCode",
"settings.providers.oauth.popup.loadingBody": "Preparando página de autorización...",
"settings.providers.oauth.codeLabel": "Código de autorización",
"settings.providers.oauth.codePlaceholder": "Pega el código del proveedor",
"settings.providers.oauth.submitCode": "Enviar código",
"settings.providers.oauth.waitingTitle": "Esperando autorización",
"settings.providers.oauth.cancelWait": "Cancelar",
"settings.providers.prompt.selectPlaceholder": "Selecciona una opción",
"settings.providers.success": "Autenticación del proveedor guardada. Los datos del proveedor se actualizaron.",
"settings.providers.source.api": "Autenticación de proveedor guardada",
"settings.providers.source.config": "Configurado en opencode.jsonc",
"settings.providers.source.custom": "Configurado por lógica personalizada del proveedor",
"settings.providers.source.env": "Configurado desde variables de entorno",
"settings.providers.source.unknown": "Origen de proveedor configurado",
"settings.providers.errors.loadFailed": "No se pudieron cargar los métodos de autenticación de proveedores.",
"settings.providers.errors.noAuthorization": "El proveedor no devolvió datos de autorización.",
"settings.providers.errors.authorizationFailed": "La autorización falló.",
"settings.providers.errors.removeFailed": "No se pudo quitar la autenticación del proveedor.",
"settings.providers.errors.envDisconnectUnavailable": "Este proveedor está configurado desde variables de entorno y no se puede desconectar desde la UI.",
"settings.appearance.behavior.title": "Interaccion",
"settings.appearance.behavior.title": "Interacción",
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
"settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets",
"settings.behavior.expansionPreset.minimal.title": "Minimal",
"settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.",
"settings.behavior.expansionPreset.balanced.title": "Balanced",
"settings.behavior.expansionPreset.balanced.description": "Show important actions while keeping noisy reads and fetches compact.",
"settings.behavior.expansionPreset.detailed.title": "Detailed",
"settings.behavior.expansionPreset.detailed.description": "Open most tool and thinking output for a fuller transcript.",
"settings.behavior.expansionPreset.everything.title": "Everything",
"settings.behavior.expansionPreset.everything.description": "Expand all tool and thinking output by default.",
"settings.behavior.expansionPreset.custom.title": "Custom",
"settings.behavior.expansionPreset.custom.description": "Your customized transcript detail defaults.",
"settings.behavior.expansionDefaults.title": "Expansion defaults",
"settings.behavior.expansionDefaults.itemColumn": "Item",
"settings.behavior.expansionDefaults.stateColumn": "Default state",
"settings.behavior.expansionDefaults.thinking": "thinking",
"settings.behavior.expansionDefaults.otherTools": "Other tools",
"settings.behavior.expansionDefaults.rowAriaLabel": "Default state for {item}",
"settings.behavior.keyboardHints.title": "Sugerencias de atajos de teclado",
"settings.behavior.keyboardHints.subtitle": "Muestra sugerencias de atajos de teclado en toda la interfaz.",
"settings.behavior.thinking.title": "Secciones de pensamiento",
"settings.behavior.thinking.subtitle": "Muestra u oculta las secciones de pensamiento de la IA en los mensajes.",
"settings.behavior.thinkingDefault.title": "Pensamiento por defecto",
"settings.behavior.thinkingDefault.subtitle": "Elige si las secciones de pensamiento comienzan expandidas o contraidas.",
"settings.behavior.messageTimeline.title": "Linea de tiempo de mensajes",
"settings.behavior.messageTimeline.subtitle": "Muestra u oculta la barra lateral de la linea de tiempo de mensajes.",
"settings.behavior.timelineTools.title": "Llamadas de herramientas en la linea de tiempo",
"settings.behavior.timelineTools.subtitle": "Muestra u oculta entradas de llamadas de herramientas en la linea de tiempo de mensajes.",
"settings.behavior.thinkingDefault.subtitle": "Elige si las secciones de pensamiento comienzan expandidas o contraídas.",
"settings.behavior.messageTimeline.title": "Línea de tiempo de mensajes",
"settings.behavior.messageTimeline.subtitle": "Muestra u oculta la barra lateral de la línea de tiempo de mensajes.",
"settings.behavior.timelineTools.title": "Llamadas de herramientas en la línea de tiempo",
"settings.behavior.timelineTools.subtitle": "Muestra u oculta entradas de llamadas de herramientas en la línea de tiempo de mensajes.",
"settings.behavior.diffView.title": "Vista de diferencias",
"settings.behavior.diffView.subtitle": "Elige como se muestran los diffs de llamadas de herramientas.",
"settings.behavior.diffView.subtitle": "Elige cómo se muestran los diffs de llamadas de herramientas.",
"settings.behavior.diffView.option.split": "Dividida",
"settings.behavior.diffView.option.unified": "Unificada",
"settings.behavior.toolOutputsDefault.title": "Salidas de herramientas por defecto",
"settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraidas.",
"settings.behavior.diagnosticsDefault.title": "Diagnosticos por defecto",
"settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnosticos comienza expandida o contraida.",
"settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraídas.",
"settings.behavior.diagnosticsDefault.title": "Diagnósticos por defecto",
"settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnósticos comienza expandida o contraída.",
"settings.behavior.toolInputsVisibility.title": "Visibilidad de entradas de herramientas",
"settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.",
"settings.behavior.usageMetrics.title": "Metricas de uso de tokens",
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
"settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
"settings.behavior.usageMetrics.title": "Métricas de uso de tokens",
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadísticas de tokens y costo en mensajes del asistente.",
"settings.behavior.autoCleanup.title": "Limpieza automática de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automáticamente las sesiones en blanco al crear nuevas.",
"settings.behavior.keepUnseenSubagentIdle.title": "Mantener marcadores idle de subagentes",
"settings.behavior.keepUnseenSubagentIdle.subtitle": "Mantiene visibles los marcadores idle de subagentes hasta verlos, en lugar de ocultarlos despues de 5 segundos.",
"settings.behavior.keepUnseenSubagentIdle.subtitle": "Mantiene visibles los marcadores idle de subagentes hasta verlos, en lugar de ocultarlos después de 5 segundos.",
"settings.behavior.tauriNativeEventTransport.title": "Transporte de eventos nativo de Tauri",
"settings.behavior.tauriNativeEventTransport.subtitle": "Usa el transporte de eventos de escritorio nativo en Rust dentro de Tauri. Desactivalo para volver a la ruta browser EventSource.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.tauriNativeEventTransport.subtitle": "Usa el transporte de eventos de escritorio nativo en Rust dentro de Tauri. Desactívalo para volver a la ruta browser EventSource.",
"settings.behavior.promptVoiceInput.title": "Entrada de voz del prompt",
"settings.behavior.promptVoiceInput.subtitle": "Muestra el control del micrófono para la entrada de voz a texto del prompt cuando la voz esté configurada.",
"settings.behavior.promptSubmit.title": "Enter para enviar",
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva línea.",
"settings.speech.title": "Voz",
"settings.speech.subtitle": "Configura ahora el reconocimiento de voz y prepara la base de texto a voz para funciones futuras.",
"settings.speech.provider.title": "Proveedor",
"settings.speech.provider.subtitle": "Las solicitudes de voz usan el adaptador de voz del servidor.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.provider.openaiCompatible": "Compatible con OpenAI",
"settings.speech.status.loading": "Comprobando configuración...",
"settings.speech.status.configured": "Configurado",
"settings.speech.status.missing": "Falta la clave API",
"settings.speech.status.error": "Servicio de voz no disponible",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.title": "Clave API",
"settings.speech.apiKey.subtitle": "Se usa para las solicitudes de voz gestionadas por CodeNomad.",
"settings.speech.apiKey.placeholder": "Introduce una nueva clave API",
"settings.speech.apiKey.storedNote": "Hay una clave API guardada y oculta. Introduce un nuevo valor para reemplazarla o deja el campo vacío para conservarla.",
"settings.speech.apiKey.clearAction": "Borrar clave guardada",
"settings.speech.apiKey.clearPending": "La clave API guardada se eliminará al guardar.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.title": "URL base",
"settings.speech.baseUrl.subtitle": "Anulación opcional para endpoints de voz compatibles con OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Modelo de transcripción",
@ -265,62 +282,62 @@ export const settingsMessages = {
"settings.speech.ttsModel.subtitle": "Modelo predeterminado de texto a voz reservado para futuras funciones de reproducción.",
"settings.speech.ttsVoice.title": "Voz predeterminada",
"settings.speech.ttsVoice.subtitle": "Voz predeterminada de texto a voz reservada para futuras funciones de reproducción.",
"settings.speech.playbackMode.title": "Modo de reproduccion",
"settings.speech.playbackMode.subtitle": "Elige si TTS empieza a reproducirse mientras llega el audio o despues de generar el archivo completo.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.playbackMode.title": "Modo de reproducción",
"settings.speech.playbackMode.subtitle": "Elige si TTS empieza a reproducirse mientras llega el audio o después de generar el archivo completo.",
"settings.speech.playbackMode.streaming": "En streaming",
"settings.speech.playbackMode.buffered": "Con búfer",
"settings.speech.ttsFormat.title": "Formato de salida",
"settings.speech.ttsFormat.subtitle": "Elige el formato de audio para la voz sintetizada. La compatibilidad de streaming depende de tu proveedor y navegador.",
"settings.speech.help": "La entrada de voz del prompt aparece cuando la transcripcion de voz esta configurada y es compatible. La reproduccion de mensajes usa el modo y formato TTS seleccionados aqui.",
"settings.speech.compatibility.streamingUnavailable": "Tu configuracion actual del proveedor de voz no anuncia TTS por streaming. Cambia el modo de reproduccion a buffered si quieres que la reproduccion funcione ahora.",
"settings.speech.compatibility.browserStreamingUnavailable": "Tu navegador actual no puede reproducir por streaming el formato TTS seleccionado. Elige reproduccion buffered o cambia a otro formato.",
"settings.speech.compatibility.runtimeNote": "Todos los formatos siguen disponibles en modo streaming. Algunas combinaciones de navegador y proveedor aun pueden fallar al reproducir.",
"settings.speech.testPlayback.action": "Probar reproduccion",
"settings.speech.help": "La entrada de voz del prompt aparece cuando la transcripción de voz está configurada y es compatible. La reproducción de mensajes usa el modo y formato TTS seleccionados aquí.",
"settings.speech.compatibility.streamingUnavailable": "Tu configuración actual del proveedor de voz no anuncia TTS por streaming. Cambia el modo de reproducción a con búfer si quieres que la reproducción funcione ahora.",
"settings.speech.compatibility.browserStreamingUnavailable": "Tu navegador actual no puede reproducir por streaming el formato TTS seleccionado. Elige reproducción con búfer o cambia a otro formato.",
"settings.speech.compatibility.runtimeNote": "Todos los formatos siguen disponibles en modo streaming. Algunas combinaciones de navegador y proveedor aún pueden fallar al reproducir.",
"settings.speech.testPlayback.action": "Probar reproducción",
"settings.speech.testPlayback.generating": "Generando muestra",
"settings.speech.testPlayback.stop": "Detener muestra",
"settings.speech.testPlayback.sample": "Gracias por usar CodeNomad, tu configuracion de voz funciona correctamente.",
"settings.speech.testPlayback.note": "La prueba usa de inmediato el modo y formato actuales. Guarda primero los cambios de API key, base URL, modelo o voz si tambien quieres probarlos.",
"settings.speech.testPlayback.sample": "Gracias por usar CodeNomad, tu configuración de voz funciona correctamente.",
"settings.speech.testPlayback.note": "La prueba usa de inmediato el modo y formato actuales. Guarda primero los cambios de clave API, URL base, modelo o voz si también quieres probarlos.",
"settings.speech.save.action": "Guardar",
"settings.speech.save.saving": "Guardando...",
"settings.speech.save.saved": "Guardado",
"settings.speech.save.unsaved": "Cambios sin guardar",
"settings.speech.save.error": "Error al guardar",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.eyebrow": "Servicios del servidor",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"settings.section.sidecars.subtitle": "Configura servicios locales que escuchan en puertos fijos y que el servidor puede proxificar en pestañas.",
"sidecars.form.name": "Nombre",
"sidecars.form.validation": "Introduce un nombre y puerto de SideCar válidos.",
"sidecars.form.port": "Puerto",
"sidecars.form.insecure": "Usar HTTP",
"sidecars.form.protocol": "Protocolo",
"sidecars.form.protocol.help": "Elige cómo debe conectarse el proxy al servicio local.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
"sidecars.form.prefixMode": "Modo de prefijo",
"sidecars.form.prefixMode.help": "Elige si el SideCar recibe el prefijo de ruta pública /sidecars.",
"sidecars.form.prefixMode.strip": "Quitar prefijo",
"sidecars.form.prefixMode.preserve": "Conservar prefijo",
"sidecars.form.add": "Añadir SideCar",
"sidecars.kind.port": "Puerto",
"sidecars.status.running": "En ejecución",
"sidecars.status.stopped": "Detenido",
"sidecars.basePath": "Ruta base",
"sidecars.settings.listTitle": "SideCars configurados",
"sidecars.settings.listSubtitle": "Revisa los SideCars basados en puertos disponibles en el selector.",
"sidecars.settings.empty": "Aún no hay SideCars configurados.",
"sidecars.picker.title": "Abrir SideCar",
"sidecars.picker.loading": "Cargando SideCars...",
"sidecars.picker.subtitle": "Elige un SideCar disponible para abrirlo en una pestaña nueva.",
"sidecars.picker.empty": "Aún no hay SideCars basados en puertos disponibles.",
"sidecars.picker.close": "Cerrar",
"sidecars.open.errorTitle": "No se pudo abrir el SideCar",
"sidecars.open.notFound": "No se encontró el SideCar.",
"sidecars.open.notRunning": "No se puede acceder al SideCar en su puerto configurado.",
"sidecars.back": "Volver",
"sidecars.refresh": "Actualizar",
"sidecars.path": "Ruta",
"sidecars.go": "Ir",
// Toast History Panel
"toastHistory.title": "Historial de notificaciones",
@ -343,25 +360,25 @@ export const settingsMessages = {
"toastHistory.deleteItem": "Eliminar notificación",
// Info Section
"settings.section.info.title": "About",
"settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.",
"settings.info.version.server": "Server version",
"settings.info.version.ui": "UI version",
"settings.info.version.uiSource": "UI source",
"settings.section.info.title": "Acerca de",
"settings.section.info.subtitle": "Consulta la versión, el runtime y recopila información de diagnóstico.",
"settings.info.version.server": "Versión del servidor",
"settings.info.version.ui": "Versión de la UI",
"settings.info.version.uiSource": "Origen de la UI",
"settings.info.runtime.type": "Runtime",
"settings.info.runtime.platform": "Platform",
"settings.info.runtime.os": "Operating system",
"settings.info.server.url": "Server URL",
"settings.info.server.root": "Workspace root",
"settings.info.updates.title": "Updates",
"settings.info.updates.subtitle": "Check for new releases of CodeNomad.",
"settings.info.updates.latest": "Latest version",
"settings.info.updates.download": "Download update",
"settings.info.updates.refresh": "Refresh",
"settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.",
"settings.info.diagnostics.title": "Diagnostics",
"settings.info.runtime.platform": "Plataforma",
"settings.info.runtime.os": "Sistema operativo",
"settings.info.server.url": "URL del servidor",
"settings.info.server.root": "Raíz del workspace",
"settings.info.updates.title": "Actualizaciones",
"settings.info.updates.subtitle": "Busca nuevas releases de CodeNomad.",
"settings.info.updates.latest": "Última versión",
"settings.info.updates.download": "Descargar actualización",
"settings.info.updates.refresh": "Actualizar",
"settings.info.diagnostics.copyFailed": "Acceso al portapapeles denegado. Usa el botón de descarga en su lugar.",
"settings.info.diagnostics.title": "Diagnósticos",
"settings.info.diagnostics.subtitle": "Recopila información del sistema y de la versión para informes de errores.",
"settings.info.diagnostics.copy": "Copy to clipboard",
"settings.info.diagnostics.download": "Download .txt",
"settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.",
"settings.info.diagnostics.copy": "Copiar al portapapeles",
"settings.info.diagnostics.download": "Descargar .txt",
"settings.info.diagnostics.copied": "Información de diagnóstico copiada al portapapeles.",
} as const

View file

@ -2,16 +2,22 @@ export const toolCallMessages = {
"toolCall.pending.waitingToRun": "Esperando para ejecutar...",
"toolCall.error.label": "Error:",
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.copyTitle": "Copiar título de la llamada de herramienta",
"toolCall.header.copyAriaLabel": "Copiar título de la llamada de herramienta",
"toolCall.header.copyOutputTitle": "Copiar salida de herramienta",
"toolCall.header.copyOutputAriaLabel": "Copiar salida de herramienta",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.header.showInputTitle": "Mostrar argumentos de herramienta",
"toolCall.header.showInputAriaLabel": "Mostrar argumentos de herramienta",
"toolCall.header.hideInputTitle": "Ocultar argumentos de herramienta",
"toolCall.header.hideInputAriaLabel": "Ocultar argumentos de herramienta",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.io.input": "Entrada de herramienta",
"toolCall.io.output": "Salida de herramienta",
"toolCall.io.copyInputTitle": "Copiar entrada de herramienta",
"toolCall.io.copyInputAriaLabel": "Copiar entrada de herramienta",
"toolCall.io.copyOutputTitle": "Copiar salida de herramienta",
"toolCall.io.copyOutputAriaLabel": "Copiar salida de herramienta",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
@ -53,7 +59,7 @@ export const toolCallMessages = {
"toolCall.renderer.read.detail.offset": "Desplazamiento: {offset}",
"toolCall.renderer.read.detail.limit": "Límite: {limit}",
"toolCall.renderer.todo.empty": "Aún no hay elementos del plan.",
"toolCall.renderer.todo.empty": "No hay elementos planificados",
"toolCall.renderer.todo.status.pending": "Pendiente",
"toolCall.renderer.todo.status.inProgress": "En progreso",
"toolCall.renderer.todo.status.completed": "Completado",
@ -71,13 +77,9 @@ export const toolCallMessages = {
"toolCall.permission.actions.allowOnce": "Permitir una vez",
"toolCall.permission.actions.alwaysAllow": "Permitir siempre",
"toolCall.permission.actions.deny": "Denegar",
"toolCall.permission.actions.confirmDeny": "Confirmar denegación",
"toolCall.permission.actions.cancel": "Cancelar",
"toolCall.permission.shortcuts.allowOnce": "Permitir una vez",
"toolCall.permission.shortcuts.alwaysAllow": "Permitir siempre",
"toolCall.permission.rejectReason.label": "¿Por qué denegar este permiso?",
"toolCall.permission.rejectReason.placeholder": "Indica a CodeNomad qué debe hacer de otra forma...",
"toolCall.permission.rejectReason.hint": "El agente verá estos comentarios y podrá ajustar su siguiente acción.",
"toolCall.permission.rejectReason.placeholder": "Comentario opcional al denegar, p. ej. usa un comando más seguro...",
"toolCall.permission.errors.unableToUpdate": "No se pudo actualizar el permiso",
"permissionApproval.title": "Solicitudes",
@ -94,11 +96,7 @@ export const toolCallMessages = {
"permissionApproval.actions.allowOnce": "Permitir una vez",
"permissionApproval.actions.alwaysAllow": "Permitir siempre",
"permissionApproval.actions.deny": "Denegar",
"permissionApproval.actions.confirmDeny": "Confirmar denegación",
"permissionApproval.actions.cancel": "Cancelar",
"permissionApproval.rejectReason.label": "¿Por qué denegar este permiso?",
"permissionApproval.rejectReason.placeholder": "Indica a CodeNomad qué debe hacer de otra forma...",
"permissionApproval.rejectReason.hint": "El agente verá estos comentarios y podrá ajustar su siguiente acción.",
"permissionApproval.rejectReason.placeholder": "Comentario opcional al denegar, p. ej. usa un comando más seguro...",
"permissionApproval.fallbackHint": "Carga la sesión para más información.",
"permissionApproval.errors.unableToUpdatePermission": "No se pudo actualizar el permiso",

View file

@ -12,6 +12,7 @@ export const appMessages = {
"app.stopInstance.title": "Arrêter l'instance",
"app.stopInstance.confirmLabel": "Arrêter",
"app.stopInstance.cancelLabel": "Laisser tourner",
"app.stopInstance.toast.error": "Impossible d'arrêter l'espace de travail.",
"emptyState.logoAlt": "Logo CodeNomad",
"emptyState.brandTitle": "CodeNomad",
@ -33,4 +34,10 @@ export const appMessages = {
"releases.devUpdateAvailable.title": "Build dev disponible",
"releases.devUpdateAvailable.message": "Un nouveau build dev est disponible : {version}.",
"releases.devUpdateAvailable.action": "Voir la release",
"theme.mode.system": "Système",
"theme.mode.light": "Clair",
"theme.mode.dark": "Sombre",
"theme.toggle.title": "Thème : {mode}",
"theme.toggle.ariaLabel": "Thème : {mode}",
} as const

View file

@ -168,6 +168,7 @@ export const commandMessages = {
"unifiedPicker.sections.commands": "COMMANDES",
"unifiedPicker.sections.agents": "AGENTS",
"unifiedPicker.sections.files": "FICHIERS",
"unifiedPicker.sections.workspaceRoot": "RACINE DE L'ESPACE DE TRAVAIL",
"unifiedPicker.badge.subagent": "sous-agent",
"unifiedPicker.footer.navigate": "naviguer",
"unifiedPicker.footer.select": "sélectionner",

View file

@ -95,7 +95,7 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.confirmLabel": "Continuer",
"folderSelection.servers.certificateInstall.cancelLabel": "Annuler",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.",
"folderSelection.sidecars.button": "Open SideCar",
"folderSelection.sidecars.button": "Ouvrir SideCar",
"projectRenameDialog.title": "Renommer l'espace de travail",
"projectRenameDialog.description.withLabel": "Mettez à jour le nom de l'espace de travail pour \"{label}\".",

View file

@ -119,6 +119,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins qui personnalisent le comportement de l'UI et du serveur, ajoutant des fonctionnalités au-delà de MCP et LSP.",
"instanceShell.gitChanges.noSessionSelected": "Sélectionnez une session pour voir les changements Git.",
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
"instanceShell.gitChanges.deleted": "Supprimé",
@ -151,6 +152,15 @@ export const instanceMessages = {
"instanceShell.filesShell.showSource": "Afficher la source",
"instanceShell.filesShell.enableWordWrap": "Activer le retour à la ligne",
"instanceShell.filesShell.disableWordWrap": "Désactiver le retour à la ligne",
"instanceShell.filesShell.hideFiles": "Masquer les fichiers",
"instanceShell.filesShell.showFiles": "Afficher les fichiers",
"instanceShell.diff.hideUnchanged": "Masquer les zones inchangées",
"instanceShell.diff.showFull": "Afficher le fichier complet",
"instanceShell.diff.switchToSplit": "Passer à la vue côte à côte",
"instanceShell.diff.switchToUnified": "Passer à la vue unifiée",
"instanceShell.diff.enableWordWrap": "Activer le retour à la ligne",
"instanceShell.diff.disableWordWrap": "Désactiver le retour à la ligne",
"instanceShell.worktree.create": "+ Créer un worktree",
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
"instanceShell.plan.empty": "Aucun plan pour l'instant.",

View file

@ -20,6 +20,7 @@ export const messagingMessages = {
"messageSection.loadError.reload": "Recharger les messages",
"messageSection.scroll.toFirstAriaLabel": "Aller au premier message",
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
"messageSection.scroll.showControlsAriaLabel": "Afficher les contrôles de défilement",
"messageSection.scroll.enableHoldAriaLabel": "Activer le maintien pour les longues réponses de l'assistant",
"messageSection.scroll.disableHoldAriaLabel": "Désactiver le maintien pour les longues réponses de l'assistant",
"messageSection.quote.addAsQuote": "Ajouter en citation",
@ -43,7 +44,7 @@ export const messagingMessages = {
"messageTimeline.ariaLabel": "Chronologie des messages",
"messageTimeline.segment.user.label": "Vous",
"messageTimeline.segment.assistant.label": "Asst",
"messageTimeline.segment.compaction.label": "Compaction",
"messageTimeline.segment.compaction.label": "Compactage",
"messageTimeline.tool.fallbackLabel": "Appel d'outil",
"messageTimeline.tooltip.userFallback": "Message utilisateur",
"messageTimeline.tooltip.assistantFallback": "Réponse de l'assistant",
@ -80,6 +81,15 @@ export const messagingMessages = {
"messageBlock.reasoning.indicator.hide": "Masquer",
"messageBlock.reasoning.indicator.view": "Voir",
"messageBlock.reasoning.detailsAriaLabel": "Détails du raisonnement",
"messageBlock.reasoning.thoughtsFallback": "Réflexions",
"messageBlock.reasoning.thoughtFor.seconds.one": "Réflexion pendant {count} seconde",
"messageBlock.reasoning.thoughtFor.seconds.other": "Réflexion pendant {count} secondes",
"messageBlock.reasoning.thoughtFor.minutes.one": "Réflexion pendant {count} minute",
"messageBlock.reasoning.thoughtFor.minutes.other": "Réflexion pendant {count} minutes",
"messageBlock.reasoning.thoughtFor.hours.one": "Réflexion pendant {count} heure",
"messageBlock.reasoning.thoughtFor.hours.other": "Réflexion pendant {count} heures",
"messageBlock.reasoning.copyTitle": "Copier les réflexions",
"messageBlock.reasoning.copyAriaLabel": "Copier les réflexions",
"codeBlockInline.actions.copy": "Copier",
"codeBlockInline.actions.copied": "Copié !",
@ -88,8 +98,8 @@ export const messagingMessages = {
"messageItem.speaker.assistant": "Assistant",
"messageItem.actions.revert": "Revenir",
"messageItem.actions.revertTitle": "Annuler les changements jusqu'ici (supprime les messages)",
"messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork depuis ce message",
"messageItem.actions.fork": "Bifurquer",
"messageItem.actions.forkTitle": "Bifurquer depuis ce message",
"messageItem.actions.copy": "Copier",
"messageItem.actions.copyTitle": "Copier le message",
"messageItem.actions.copied": "Copié !",
@ -108,6 +118,14 @@ export const messagingMessages = {
"messageItem.actions.deleteMessageFailedMessage": "Impossible de supprimer le message",
"messageItem.selection.checkboxAriaLabel": "Sélectionner le message pour suppression",
"messageItem.selection.selectForDeletion": "Sélectionner le message pour suppression",
"messageItem.selection.deselectForDeletion": "Désélectionner le message pour suppression",
"messageItem.duration.workedFor.seconds.one": "A travaillé pendant {count} seconde",
"messageItem.duration.workedFor.seconds.other": "A travaillé pendant {count} secondes",
"messageItem.duration.workedFor.minutes.one": "A travaillé pendant {count} minute",
"messageItem.duration.workedFor.minutes.other": "A travaillé pendant {count} minutes",
"messageItem.duration.workedFor.hours.one": "A travaillé pendant {count} heure",
"messageItem.duration.workedFor.hours.other": "A travaillé pendant {count} heures",
"messageSection.bulkDelete.toolbarAriaLabel": "Éléments sélectionnés ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Supprimer les éléments sélectionnés",

View file

@ -32,7 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "Afficher tous les modèles",
"modelSelector.favorite.add": "Ajouter aux favoris",
"modelSelector.favorite.remove": "Retirer des favoris",
"modelSelector.manageProviders": "Manage Providers",
"modelSelector.manageProviders": "Gérer les fournisseurs",
"thinkingSelector.variant.default": "Par défaut",
"thinkingSelector.label": "Réflexion : {variant}",
@ -64,66 +64,66 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "Dispo",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.title": "Paramètres",
"settings.navigationAriaLabel": "Sections des paramètres",
"settings.close": "Fermer les paramètres",
"settings.content.eyebrow": "Préférences de l'espace de travail",
"settings.open.title": "Ouvrir les paramètres",
"settings.open.ariaLabel": "Ouvrir les paramètres",
"settings.nav.appearance": "Apparence",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.providers": "Providers",
"settings.nav.remote": "Accès distant",
"settings.nav.speech": "Voix",
"settings.nav.providers": "Fournisseurs",
"settings.nav.opencode": "OpenCode",
"settings.nav.configFiles": "Config Files",
"settings.nav.configFiles": "Fichiers de configuration",
"settings.nav.info": "Info",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Desactive",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.scope.device": "Cet appareil",
"settings.scope.server": "Paramètre serveur",
"settings.common.enabled": "Activé",
"settings.common.disabled": "Désactivé",
"settings.section.appearance.title": "Apparence",
"settings.section.appearance.subtitle": "Ajustez l'apparence de l'application sur cet appareil.",
"settings.appearance.theme.title": "Thème",
"settings.appearance.theme.subtitle": "Choisissez le mode de couleur utilisé dans toute l'application.",
"settings.appearance.theme.option.system": "Suivre le réglage de votre système d'exploitation",
"settings.appearance.theme.option.light": "Utiliser l'apparence claire",
"settings.appearance.theme.option.dark": "Utiliser l'apparence sombre",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.notifications.subtitle": "Contrôlez les notifications système liées à l'activité des sessions.",
"settings.notifications.permission.granted": "Accordée",
"settings.notifications.permission.denied": "Refusée",
"settings.notifications.permission.default": "Non accordée",
"settings.notifications.permission.unsupported": "Non prise en charge",
"settings.notifications.messages.unsupportedEnvironment": "Les notifications système ne sont pas prises en charge dans cet environnement.",
"settings.notifications.messages.permissionDenied": "Autorisation de notification refusée. Activez les notifications dans les paramètres de votre système ou navigateur.",
"settings.notifications.messages.permissionNotGranted": "Autorisation de notification non accordée.",
"settings.notifications.messages.unsupportedGeneral": "Les notifications ne sont pas prises en charge dans cet environnement.",
"settings.notifications.messages.permissionGranted": "Autorisation accordée. Vous pouvez maintenant activer les notifications.",
"settings.notifications.messages.permissionRequestDenied": "Autorisation refusée. Vous devrez peut-être activer les notifications dans les paramètres de votre système ou navigateur.",
"settings.notifications.sessionStatus.title": "Notifications d'état de session",
"settings.notifications.sessionStatus.subtitle": "Recevez des alertes lorsque des sessions nécessitent votre attention.",
"settings.notifications.enable.title": "Activer les notifications",
"settings.notifications.enable.permission": "Autorisation : {permission}",
"settings.notifications.requestPermission.title": "Demander l'autorisation",
"settings.notifications.requestPermission.subtitle": "Autoriser l'application à envoyer des notifications sur cet appareil.",
"settings.notifications.requestPermission.action": "Demander",
"settings.notifications.allowVisible.title": "Notifier lorsque l'application est au premier plan",
"settings.notifications.allowVisible.subtitle": "Garder les alertes activées même lorsque cette fenêtre est visible.",
"settings.notifications.unsupportedNote": "Les notifications ne sont pas prises en charge dans cet environnement. Le contrôle des notifications reste désactivé.",
"settings.notifications.events.title": "Me notifier lorsque",
"settings.notifications.events.subtitle": "Choisissez les événements de session qui doivent envoyer des alertes.",
"settings.notifications.events.needsInput": "Une session nécessite une saisie",
"settings.notifications.events.idle": "Une session devient inactive",
"settings.notifications.status.enabled": "Notifications activées",
"settings.notifications.status.disabled": "Notifications désactivées",
"settings.notifications.status.unsupported": "Notifications non prises en charge",
"settings.section.remote.title": "Accès distant",
"settings.section.remote.subtitle": "Vérifiez comment ce serveur est exposé sur votre réseau et sécurisez les identifiants d'accès.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.section.opencode.subtitle": "Choisissez le binaire OpenCode et l'environnement utilisés pour les nouvelles instances.",
"settings.opencode.runtime.title": "Environnement d'exécution",
"settings.opencode.runtime.subtitle": "Configurez le binaire OpenCode avec lequel les nouvelles instances démarrent.",
"settings.opencode.logLevel.title": "Niveau de logs OpenCode",
"settings.opencode.logLevel.subtitle": "Definir le niveau de logs utilise au lancement des nouvelles instances OpenCode.",
"settings.opencode.logLevel.selector.title": "Verbosite des logs",
@ -132,83 +132,100 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "Info",
"settings.opencode.logLevel.option.warn": "Avertissement",
"settings.opencode.logLevel.option.error": "Erreur",
"settings.configFiles.title": "Config Files",
"settings.configFiles.subtitle": "Edit allowlisted global configuration files on this server.",
"settings.configFiles.selector.label": "File",
"settings.configFiles.selector.placeholder": "Select config file",
"settings.configFiles.actions.save": "Save",
"settings.configFiles.actions.reload": "Reload",
"settings.configFiles.state.unsaved": "Unsaved changes",
"settings.configFiles.state.notCreated": "File will be created on save",
"settings.configFiles.empty": "No config file selected.",
"settings.configFiles.errors.loadList": "Failed to load config files.",
"settings.configFiles.errors.loadContent": "Failed to load config file.",
"settings.configFiles.errors.save": "Failed to save config file.",
"settings.configFiles.toast.saveSuccess": "Config file saved.",
"settings.configFiles.toast.saveError": "Failed to save config file.",
"settings.configFiles.confirmDiscard.message": "Discard unsaved changes before continuing?",
"settings.configFiles.confirmDiscard.confirmLabel": "Discard changes",
"settings.configFiles.confirmDiscard.cancelLabel": "Keep editing",
"settings.configFiles.title": "Fichiers de configuration",
"settings.configFiles.subtitle": "Modifiez les fichiers de configuration globaux autorisés sur ce serveur.",
"settings.configFiles.selector.label": "Fichier",
"settings.configFiles.selector.placeholder": "Sélectionner un fichier de configuration",
"settings.configFiles.actions.save": "Enregistrer",
"settings.configFiles.actions.reload": "Recharger",
"settings.configFiles.state.unsaved": "Modifications non enregistrées",
"settings.configFiles.state.notCreated": "Le fichier sera créé lors de l'enregistrement",
"settings.configFiles.empty": "Aucun fichier de configuration sélectionné.",
"settings.configFiles.errors.loadList": "Échec du chargement des fichiers de configuration.",
"settings.configFiles.errors.loadContent": "Échec du chargement du fichier de configuration.",
"settings.configFiles.errors.save": "Échec de l'enregistrement du fichier de configuration.",
"settings.configFiles.toast.saveSuccess": "Fichier de configuration enregistré.",
"settings.configFiles.toast.saveError": "Échec de l'enregistrement du fichier de configuration.",
"settings.configFiles.confirmDiscard.message": "Ignorer les modifications non enregistrées avant de continuer ?",
"settings.configFiles.confirmDiscard.confirmLabel": "Ignorer les modifications",
"settings.configFiles.confirmDiscard.cancelLabel": "Continuer la modification",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.title": "Authentification des fournisseurs",
"settings.providers.subtitle": "Connectez des clés API ou des comptes OAuth pour l'instance OpenCode active.",
"settings.providers.refresh": "Actualiser",
"settings.providers.selectProvider": "Sélectionner un fournisseur",
"settings.providers.available.title": "Fournisseurs disponibles",
"settings.providers.configured.title": "Fournisseurs configurés",
"settings.providers.loading": "Chargement des méthodes d'authentification des fournisseurs...",
"settings.providers.empty.noInstance": "Démarrez un espace de travail OpenCode avant de gérer l'authentification des fournisseurs.",
"settings.providers.empty.noProviders": "Aucun fournisseur n'est disponible depuis cette instance OpenCode.",
"settings.providers.empty.noConfiguredProviders": "Aucun fournisseur n'est actuellement configuré pour cette instance OpenCode.",
"settings.providers.models.one": "{count} modèle",
"settings.providers.models.other": "{count} modèles",
"settings.providers.actions.connect": "Connecter",
"settings.providers.actions.disconnect": "Déconnecter",
"settings.providers.actions.remove": "Supprimer l'authentification enregistrée",
"settings.providers.actions.close": "Fermer",
"settings.providers.actions.continue": "Continuer",
"settings.providers.actions.working": "Traitement...",
"settings.providers.method.title": "Méthode d'authentification",
"settings.providers.method.subtitle": "Choisissez le flux exposé par OpenCode pour ce fournisseur.",
"settings.providers.method.api": "Clé API",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.providers.method.mixed": "Clé API et OAuth",
"settings.providers.method.fallback": "Clé API",
"settings.providers.status.available": "Disponible",
"settings.providers.auth.title": "Connecter {provider}",
"settings.providers.auth.subtitle": "L'authentification est enregistrée dans le serveur OpenCode actif.",
"settings.providers.apiKey.label": "Clé API",
"settings.providers.apiKey.placeholder": "Coller la clé API du fournisseur",
"settings.providers.oauth.noPrompts": "Cette méthode OAuth ne nécessite aucun détail supplémentaire avant l'ouverture du navigateur.",
"settings.providers.oauth.promptsUnsupported": "Cette méthode OAuth nécessite des saisies propres au fournisseur qui ne sont pas prises en charge par la version actuelle du SDK.",
"settings.providers.oauth.enterCode": "Terminez le flux dans le navigateur, puis collez le code d'autorisation.",
"settings.providers.oauth.openPage": "Ouvrir la page d'autorisation",
"settings.providers.oauth.openHere": "Ouvrir dans cet onglet",
"settings.providers.oauth.copyLink": "Copier le lien",
"settings.providers.oauth.linkCopied": "Lien copié",
"settings.providers.oauth.popupBlocked": "Votre navigateur a peut-être bloqué la page d'autorisation. Utilisez les actions ci-dessus pour continuer le flux de connexion.",
"settings.providers.oauth.popup.loadingTitle": "Autorisation OpenCode",
"settings.providers.oauth.popup.loadingBody": "Préparation de la page d'autorisation...",
"settings.providers.oauth.codeLabel": "Code d'autorisation",
"settings.providers.oauth.codePlaceholder": "Coller le code du fournisseur",
"settings.providers.oauth.submitCode": "Envoyer le code",
"settings.providers.oauth.waitingTitle": "En attente de l'autorisation",
"settings.providers.oauth.cancelWait": "Annuler",
"settings.providers.prompt.selectPlaceholder": "Sélectionner une option",
"settings.providers.success": "Authentification du fournisseur enregistrée. Les données du fournisseur ont été actualisées.",
"settings.providers.source.api": "Authentification du fournisseur enregistrée",
"settings.providers.source.config": "Configuré dans opencode.jsonc",
"settings.providers.source.custom": "Configuré par une logique de fournisseur personnalisée",
"settings.providers.source.env": "Configuré depuis les variables d'environnement",
"settings.providers.source.unknown": "Source du fournisseur configuré",
"settings.providers.errors.loadFailed": "Échec du chargement des méthodes d'authentification des fournisseurs.",
"settings.providers.errors.noAuthorization": "Le fournisseur n'a pas renvoyé de données d'autorisation.",
"settings.providers.errors.authorizationFailed": "Échec de l'autorisation.",
"settings.providers.errors.removeFailed": "Échec de la suppression de l'authentification du fournisseur.",
"settings.providers.errors.envDisconnectUnavailable": "Ce fournisseur est configuré depuis des variables d'environnement et ne peut pas être déconnecté depuis l'interface.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
"settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets",
"settings.behavior.expansionPreset.minimal.title": "Minimal",
"settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.",
"settings.behavior.expansionPreset.balanced.title": "Balanced",
"settings.behavior.expansionPreset.balanced.description": "Show important actions while keeping noisy reads and fetches compact.",
"settings.behavior.expansionPreset.detailed.title": "Detailed",
"settings.behavior.expansionPreset.detailed.description": "Open most tool and thinking output for a fuller transcript.",
"settings.behavior.expansionPreset.everything.title": "Everything",
"settings.behavior.expansionPreset.everything.description": "Expand all tool and thinking output by default.",
"settings.behavior.expansionPreset.custom.title": "Custom",
"settings.behavior.expansionPreset.custom.description": "Your customized transcript detail defaults.",
"settings.behavior.expansionDefaults.title": "Expansion defaults",
"settings.behavior.expansionDefaults.itemColumn": "Item",
"settings.behavior.expansionDefaults.stateColumn": "Default state",
"settings.behavior.expansionDefaults.thinking": "thinking",
"settings.behavior.expansionDefaults.otherTools": "Other tools",
"settings.behavior.expansionDefaults.rowAriaLabel": "Default state for {item}",
"settings.behavior.keyboardHints.title": "Indications de raccourcis clavier",
"settings.behavior.keyboardHints.subtitle": "Afficher des indications de raccourcis clavier dans toute l'interface.",
"settings.behavior.thinking.title": "Sections de reflexion",
@ -237,26 +254,26 @@ export const settingsMessages = {
"settings.behavior.keepUnseenSubagentIdle.subtitle": "Garde les marqueurs inactifs des sous-agents visibles jusqu'a consultation au lieu de les masquer apres 5 secondes.",
"settings.behavior.tauriNativeEventTransport.title": "Transport d'evenements natif Tauri",
"settings.behavior.tauriNativeEventTransport.subtitle": "Utiliser le transport d'evenements desktop natif Rust dans Tauri. Desactivez-le pour revenir au chemin browser EventSource.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptVoiceInput.title": "Saisie vocale du prompt",
"settings.behavior.promptVoiceInput.subtitle": "Afficher le contrôle du microphone pour la saisie vocale du prompt lorsque la voix est configurée.",
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
"settings.speech.title": "Voix",
"settings.speech.subtitle": "Configurez dès maintenant la reconnaissance vocale et préparez la synthèse vocale pour de futures fonctionnalités.",
"settings.speech.provider.title": "Fournisseur",
"settings.speech.provider.subtitle": "Les requêtes vocales utilisent l'adaptateur vocal côté serveur.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.provider.openaiCompatible": "Compatible OpenAI",
"settings.speech.status.loading": "Vérification de la configuration...",
"settings.speech.status.configured": "Configuré",
"settings.speech.status.missing": "Clé API manquante",
"settings.speech.status.error": "Service vocal indisponible",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.title": "Clé API",
"settings.speech.apiKey.subtitle": "Utilisée pour les requêtes vocales gérées par CodeNomad.",
"settings.speech.apiKey.placeholder": "Saisissez une nouvelle clé API",
"settings.speech.apiKey.storedNote": "Une clé API enregistrée est masquée. Saisissez une nouvelle valeur pour la remplacer ou laissez le champ vide pour la conserver.",
"settings.speech.apiKey.clearAction": "Effacer la clé enregistrée",
"settings.speech.apiKey.clearPending": "La clé API enregistrée sera supprimée lors de l'enregistrement.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.title": "URL de base",
"settings.speech.baseUrl.subtitle": "Remplacement facultatif des points d'accès vocaux compatibles OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Modèle de transcription",
@ -267,8 +284,8 @@ export const settingsMessages = {
"settings.speech.ttsVoice.subtitle": "Voix de synthèse vocale par défaut réservée aux futures fonctions de lecture.",
"settings.speech.playbackMode.title": "Mode de lecture",
"settings.speech.playbackMode.subtitle": "Choisissez si le TTS commence a jouer pendant le flux audio ou apres la generation complete du fichier.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.playbackMode.streaming": "Flux continu",
"settings.speech.playbackMode.buffered": "Mis en mémoire tampon",
"settings.speech.ttsFormat.title": "Format de sortie",
"settings.speech.ttsFormat.subtitle": "Choisissez le format audio pour la voix synthetisee. La prise en charge du streaming depend du fournisseur et du navigateur.",
"settings.speech.help": "La saisie vocale du prompt apparait lorsque la transcription vocale est configuree et prise en charge. La lecture des messages utilise le mode et le format TTS selectionnes ici.",
@ -286,41 +303,41 @@ export const settingsMessages = {
"settings.speech.save.unsaved": "Modifications non enregistrées",
"settings.speech.save.error": "Échec de l'enregistrement",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.eyebrow": "Services serveur",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"settings.section.sidecars.subtitle": "Configurez les services locaux écoutant sur des ports fixes que le serveur peut relayer dans des onglets.",
"sidecars.form.name": "Nom",
"sidecars.form.validation": "Saisissez un nom et un port SideCar valides.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.insecure": "Utiliser HTTP",
"sidecars.form.protocol": "Protocole",
"sidecars.form.protocol.help": "Choisissez comment le proxy doit se connecter au service local.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.form.prefixMode": "Mode de préfixe",
"sidecars.form.prefixMode.help": "Choisissez si le SideCar reçoit le préfixe de chemin public /sidecars.",
"sidecars.form.prefixMode.strip": "Retirer le préfixe",
"sidecars.form.prefixMode.preserve": "Conserver le préfixe",
"sidecars.form.add": "Ajouter un SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
"sidecars.status.running": "En cours d'exécution",
"sidecars.status.stopped": "Arrêté",
"sidecars.basePath": "Chemin de base",
"sidecars.settings.listTitle": "SideCars configurés",
"sidecars.settings.listSubtitle": "Consultez les SideCars basés sur des ports disponibles dans le sélecteur.",
"sidecars.settings.empty": "Aucun SideCar configuré pour le moment.",
"sidecars.picker.title": "Ouvrir SideCar",
"sidecars.picker.loading": "Chargement des SideCars...",
"sidecars.picker.subtitle": "Choisissez un SideCar disponible à ouvrir dans un nouvel onglet.",
"sidecars.picker.empty": "Aucun SideCar basé sur un port n'est disponible pour le moment.",
"sidecars.picker.close": "Fermer",
"sidecars.open.errorTitle": "Impossible d'ouvrir SideCar",
"sidecars.open.notFound": "SideCar introuvable.",
"sidecars.open.notRunning": "SideCar n'est pas joignable sur son port configuré.",
"sidecars.back": "Retour",
"sidecars.refresh": "Actualiser",
"sidecars.path": "Chemin",
"sidecars.go": "Aller",
// Toast History Panel
"toastHistory.title": "Historique des notifications",
@ -343,25 +360,25 @@ export const settingsMessages = {
"toastHistory.deleteItem": "Supprimer la notification",
// Info Section
"settings.section.info.title": "About",
"settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.",
"settings.info.version.server": "Server version",
"settings.info.version.ui": "UI version",
"settings.info.version.uiSource": "UI source",
"settings.info.runtime.type": "Runtime",
"settings.info.runtime.platform": "Platform",
"settings.info.runtime.os": "Operating system",
"settings.info.server.url": "Server URL",
"settings.info.server.root": "Workspace root",
"settings.info.updates.title": "Updates",
"settings.info.updates.subtitle": "Check for new releases of CodeNomad.",
"settings.info.updates.latest": "Latest version",
"settings.info.updates.download": "Download update",
"settings.info.updates.refresh": "Refresh",
"settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.",
"settings.section.info.title": "À propos",
"settings.section.info.subtitle": "Consultez la version et l'environnement d'exécution, et recueillez des informations de diagnostic.",
"settings.info.version.server": "Version du serveur",
"settings.info.version.ui": "Version de l'interface",
"settings.info.version.uiSource": "Source de l'interface",
"settings.info.runtime.type": "Environnement d'exécution",
"settings.info.runtime.platform": "Plateforme",
"settings.info.runtime.os": "Système d'exploitation",
"settings.info.server.url": "URL du serveur",
"settings.info.server.root": "Racine de l'espace de travail",
"settings.info.updates.title": "Mises à jour",
"settings.info.updates.subtitle": "Recherchez les nouvelles versions de CodeNomad.",
"settings.info.updates.latest": "Dernière version",
"settings.info.updates.download": "Télécharger la mise à jour",
"settings.info.updates.refresh": "Actualiser",
"settings.info.diagnostics.copyFailed": "Accès au presse-papiers refusé. Utilisez plutôt le bouton de téléchargement.",
"settings.info.diagnostics.title": "Diagnostics",
"settings.info.diagnostics.subtitle": "Collectez les informations système et de version pour les rapports de bug.",
"settings.info.diagnostics.copy": "Copy to clipboard",
"settings.info.diagnostics.download": "Download .txt",
"settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.",
"settings.info.diagnostics.copy": "Copier dans le presse-papiers",
"settings.info.diagnostics.download": "Télécharger .txt",
"settings.info.diagnostics.copied": "Informations de diagnostic copiées dans le presse-papiers.",
} as const

View file

@ -2,16 +2,22 @@ export const toolCallMessages = {
"toolCall.pending.waitingToRun": "En attente d'exécution...",
"toolCall.error.label": "Erreur :",
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.copyTitle": "Copier le titre de l'appel d'outil",
"toolCall.header.copyAriaLabel": "Copier le titre de l'appel d'outil",
"toolCall.header.copyOutputTitle": "Copier la sortie de l'outil",
"toolCall.header.copyOutputAriaLabel": "Copier la sortie de l'outil",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.header.showInputTitle": "Afficher les arguments de l'outil",
"toolCall.header.showInputAriaLabel": "Afficher les arguments de l'outil",
"toolCall.header.hideInputTitle": "Masquer les arguments de l'outil",
"toolCall.header.hideInputAriaLabel": "Masquer les arguments de l'outil",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.io.input": "Entrée de l'outil",
"toolCall.io.output": "Sortie de l'outil",
"toolCall.io.copyInputTitle": "Copier l'entrée de l'outil",
"toolCall.io.copyInputAriaLabel": "Copier l'entrée de l'outil",
"toolCall.io.copyOutputTitle": "Copier la sortie de l'outil",
"toolCall.io.copyOutputAriaLabel": "Copier la sortie de l'outil",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
@ -53,7 +59,7 @@ export const toolCallMessages = {
"toolCall.renderer.read.detail.offset": "Décalage : {offset}",
"toolCall.renderer.read.detail.limit": "Limite : {limit}",
"toolCall.renderer.todo.empty": "Aucun élément de plan pour l'instant.",
"toolCall.renderer.todo.empty": "Aucun élément planifié",
"toolCall.renderer.todo.status.pending": "En attente",
"toolCall.renderer.todo.status.inProgress": "En cours",
"toolCall.renderer.todo.status.completed": "Terminé",
@ -71,13 +77,9 @@ export const toolCallMessages = {
"toolCall.permission.actions.allowOnce": "Autoriser une fois",
"toolCall.permission.actions.alwaysAllow": "Toujours autoriser",
"toolCall.permission.actions.deny": "Refuser",
"toolCall.permission.actions.confirmDeny": "Confirmer le refus",
"toolCall.permission.actions.cancel": "Annuler",
"toolCall.permission.shortcuts.allowOnce": "Autoriser une fois",
"toolCall.permission.shortcuts.alwaysAllow": "Toujours autoriser",
"toolCall.permission.rejectReason.label": "Pourquoi refuser cette autorisation ?",
"toolCall.permission.rejectReason.placeholder": "Indiquez à CodeNomad quoi faire différemment...",
"toolCall.permission.rejectReason.hint": "L'agent verra ce retour et pourra ajuster sa prochaine action.",
"toolCall.permission.rejectReason.placeholder": "Retour facultatif pour Refuser, p. ex. utilise une commande plus sûre...",
"toolCall.permission.errors.unableToUpdate": "Impossible de mettre à jour l'autorisation",
"permissionApproval.title": "Demandes",
@ -86,7 +88,7 @@ export const toolCallMessages = {
"permissionApproval.kind.question": "Question",
"permissionApproval.questionCount.one": "{count} question",
"permissionApproval.questionCount.other": "{count} questions",
"permissionApproval.status.active": "Active",
"permissionApproval.status.active": "Actif",
"permissionApproval.actions.closeAriaLabel": "Fermer",
"permissionApproval.actions.goToSession": "Aller à la session",
"permissionApproval.actions.loadingSession": "Chargement…",
@ -94,11 +96,7 @@ export const toolCallMessages = {
"permissionApproval.actions.allowOnce": "Autoriser une fois",
"permissionApproval.actions.alwaysAllow": "Toujours autoriser",
"permissionApproval.actions.deny": "Refuser",
"permissionApproval.actions.confirmDeny": "Confirmer le refus",
"permissionApproval.actions.cancel": "Annuler",
"permissionApproval.rejectReason.label": "Pourquoi refuser cette autorisation ?",
"permissionApproval.rejectReason.placeholder": "Indiquez à CodeNomad quoi faire différemment...",
"permissionApproval.rejectReason.hint": "L'agent verra ce retour et pourra ajuster sa prochaine action.",
"permissionApproval.rejectReason.placeholder": "Retour facultatif pour Refuser, p. ex. utilise une commande plus sûre...",
"permissionApproval.fallbackHint": "Chargez la session pour plus d'informations.",
"permissionApproval.errors.unableToUpdatePermission": "Impossible de mettre à jour l'autorisation",
@ -111,7 +109,7 @@ export const toolCallMessages = {
"toolCall.question.type.one": "Question",
"toolCall.question.type.other": "Questions",
"toolCall.question.number": "Q{number}:",
"toolCall.question.multiple": "Multiple",
"toolCall.question.multiple": "Multiple choix",
"toolCall.question.custom.title": "Saisir une réponse personnalisée",
"toolCall.question.custom.label": "Réponse personnalisée",
"toolCall.question.custom.placeholder": "Saisissez votre propre réponse",

View file

@ -12,6 +12,7 @@ export const appMessages = {
"app.stopInstance.title": "עצור מופע",
"app.stopInstance.confirmLabel": "עצור",
"app.stopInstance.cancelLabel": "המשך להריץ",
"app.stopInstance.toast.error": "עצירת סביבת העבודה נכשלה.",
"emptyState.logoAlt": "לוגו CodeNomad",
"emptyState.brandTitle": "CodeNomad",

View file

@ -95,7 +95,7 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.confirmLabel": "המשך",
"folderSelection.servers.certificateInstall.cancelLabel": "ביטול",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.",
"folderSelection.sidecars.button": "Open SideCar",
"folderSelection.sidecars.button": "פתח SideCar",
"projectRenameDialog.title": "שנה שם סביבת עבודה",
"projectRenameDialog.description.withLabel": "עדכן את שם סביבת העבודה עבור \"{label}\".",

View file

@ -119,6 +119,22 @@ export const instanceMessages = {
"instanceShell.rightPanel.sections.plugins": "תוספים",
"instanceShell.rightPanel.sections.plugins.tooltip": "תוספים המתאימים אישית את הממשק ואת התנהגות השרת, ומוסיפים תכונות מעבר ל-MCP ו-LSP.",
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
"instanceShell.gitChanges.deleted": "נמחק",
"instanceShell.gitChanges.binaryViewer": "לא ניתן להציג קובץ בינארי",
"instanceShell.gitChanges.sections.staged": "שינויים שנשמרו ל-staging",
"instanceShell.gitChanges.sections.unstaged": "שינויים",
"instanceShell.gitChanges.actions.insertContext": "הוסף לפרומפט",
"instanceShell.gitChanges.actions.stage": "העבר ל-staging",
"instanceShell.gitChanges.actions.unstage": "הוצא מ-staging",
"instanceShell.gitChanges.commit.placeholder": "הזן הודעת commit",
"instanceShell.gitChanges.commit.submit": "בצע commit",
"instanceShell.gitChanges.commit.submitting": "מבצע commit...",
"instanceShell.gitChanges.commit.success": "ה-commit נוצר בהצלחה",
"instanceShell.gitChanges.commit.error": "יצירת ה-commit נכשלה",
"instanceShell.filesShell.fileListTitle": "רשימת קבצים",
"instanceShell.filesShell.mobileSelectorLabel": "בחר קובץ",
"instanceShell.filesShell.mobileSelectorEmpty": "בחר קובץ",
@ -138,21 +154,6 @@ export const instanceMessages = {
"instanceShell.filesShell.showSource": "הצג מקור",
"instanceShell.filesShell.enableWordWrap": "הפעל גלישת מילים",
"instanceShell.filesShell.disableWordWrap": "כבה גלישת מילים",
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
"instanceShell.gitChanges.deleted": "נמחק",
"instanceShell.gitChanges.binaryViewer": "לא ניתן להציג קובץ בינארי",
"instanceShell.gitChanges.sections.staged": "שינויים שנשמרו ל-staging",
"instanceShell.gitChanges.sections.unstaged": "שינויים",
"instanceShell.gitChanges.actions.insertContext": "הוסף לפרומפט",
"instanceShell.gitChanges.actions.stage": "העבר ל-staging",
"instanceShell.gitChanges.actions.unstage": "הוצא מ-staging",
"instanceShell.gitChanges.commit.placeholder": "הזן הודעת commit",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "מבצע commit...",
"instanceShell.gitChanges.commit.success": "ה-commit נוצר בהצלחה",
"instanceShell.gitChanges.commit.error": "יצירת ה-commit נכשלה",
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
"instanceShell.diff.showFull": "הצג קובץ מלא",
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
@ -160,24 +161,6 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "הפעל גלישת מילים",
"instanceShell.diff.disableWordWrap": "כבה גלישת מילים",
"instanceShell.worktree.create": "+ צור worktree",
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
"instanceShell.yoloMode.noSessionSelected": "בחרו סשן כדי להגדיר מצב Yolo.",
"instanceShell.yoloMode.title": "מצב Yolo",
"instanceShell.yoloMode.description": "מאשר אוטומטית בקשות הרשאה עבור הסשן הזה. כבוי כברירת מחדל.",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "מצב Yolo פעיל",
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "התראת סיום פעילה",
"instanceShell.backgroundProcesses.notify.disabled": "התראת סיום כבויה",
"instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
"instanceShell.worktree.delete.error.title": "המחיקה נכשלה",
"instanceShell.worktree.delete.error.fallback": "מחיקת ה-worktree נכשלה",
"instanceShell.worktree.delete.error.causeLabel": "סיבה סבירה:",
@ -203,6 +186,24 @@ export const instanceMessages = {
"instanceShell.worktree.delete.error.copySanitizedSuccess": "שגיאת המחיקה המסוננת הועתקה",
"instanceShell.worktree.delete.error.copyFailure": "העתקת שגיאת המחיקה נכשלה",
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
"instanceShell.yoloMode.noSessionSelected": "בחרו סשן כדי להגדיר מצב Yolo.",
"instanceShell.yoloMode.title": "מצב Yolo",
"instanceShell.yoloMode.description": "מאשר אוטומטית בקשות הרשאה עבור הסשן הזה. כבוי כברירת מחדל.",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "מצב Yolo פעיל",
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "התראת סיום פעילה",
"instanceShell.backgroundProcesses.notify.disabled": "התראת סיום כבויה",
"instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
"versionPill.appWithVersion": "אפליקציה {version}",
"versionPill.ui": "ממשק",
"versionPill.uiWithVersion": "ממשק {version}",

View file

@ -20,6 +20,7 @@ export const messagingMessages = {
"messageSection.loadError.reload": "טען הודעות מחדש",
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
"messageSection.scroll.showControlsAriaLabel": "הצג פקדי גלילה",
"messageSection.scroll.enableHoldAriaLabel": "הפעל עצירה לתגובות עוזר ארוכות",
"messageSection.scroll.disableHoldAriaLabel": "כבה עצירה לתגובות עוזר ארוכות",
"messageSection.quote.addAsQuote": "הוסף כציטוט",
@ -78,6 +79,15 @@ export const messagingMessages = {
"messageBlock.reasoning.indicator.hide": "הסתר",
"messageBlock.reasoning.indicator.view": "צפה",
"messageBlock.reasoning.detailsAriaLabel": "פרטי חשיבה",
"messageBlock.reasoning.thoughtsFallback": "מחשבות",
"messageBlock.reasoning.thoughtFor.seconds.one": "חשב במשך {count} שנייה",
"messageBlock.reasoning.thoughtFor.seconds.other": "חשב במשך {count} שניות",
"messageBlock.reasoning.thoughtFor.minutes.one": "חשב במשך {count} דקה",
"messageBlock.reasoning.thoughtFor.minutes.other": "חשב במשך {count} דקות",
"messageBlock.reasoning.thoughtFor.hours.one": "חשב במשך {count} שעה",
"messageBlock.reasoning.thoughtFor.hours.other": "חשב במשך {count} שעות",
"messageBlock.reasoning.copyTitle": "העתק מחשבות",
"messageBlock.reasoning.copyAriaLabel": "העתק מחשבות",
"codeBlockInline.actions.copy": "העתק",
"codeBlockInline.actions.copied": "הועתק!",
@ -106,6 +116,14 @@ export const messagingMessages = {
"messageItem.actions.deleteMessageFailedMessage": "מחיקת ההודעה נכשלה",
"messageItem.selection.checkboxAriaLabel": "בחר הודעה למחיקה",
"messageItem.selection.selectForDeletion": "בחר הודעה למחיקה",
"messageItem.selection.deselectForDeletion": "בטל בחירת הודעה למחיקה",
"messageItem.duration.workedFor.seconds.one": "עבד במשך {count} שנייה",
"messageItem.duration.workedFor.seconds.other": "עבד במשך {count} שניות",
"messageItem.duration.workedFor.minutes.one": "עבד במשך {count} דקה",
"messageItem.duration.workedFor.minutes.other": "עבד במשך {count} דקות",
"messageItem.duration.workedFor.hours.one": "עבד במשך {count} שעה",
"messageItem.duration.workedFor.hours.other": "עבד במשך {count} שעות",
"messageSection.bulkDelete.toolbarAriaLabel": "פריטים נבחרים ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "מחק פריטים נבחרים",
@ -153,7 +171,6 @@ export const messagingMessages = {
"promptInput.hints.commands": "פקודות",
"promptInput.history.previousAriaLabel": "פקודה קודמת",
"promptInput.history.nextAriaLabel": "פקודה הבאה",
"promptInput.resizeHandle.title": "גרור כדי לשנות את גובה שדה הקלט",
"promptInput.overlay.newLine": "שורה חדשה",
"promptInput.overlay.send": "שלח",
"promptInput.overlay.filesAgents": "קבצים/סוכנים",
@ -189,4 +206,5 @@ export const messagingMessages = {
"promptInput.voiceInput.error.permissionDenied": "הגישה למיקרופון נדחתה על ידי macOS.",
"promptInput.voiceInput.error.unsupported": "קלט קולי אינו נתמך בדפדפן זה.",
"promptInput.voiceInput.error.transcribe": "לא ניתן היה לתמלל את האודיו שהוקלט.",
"promptInput.resizeHandle.title": "גרור כדי לשנות את גובה שדה הקלט",
} as const

View file

@ -32,7 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "הצג את כל המודלים",
"modelSelector.favorite.add": "הוסף למועדפים",
"modelSelector.favorite.remove": "הסר ממועדפים",
"modelSelector.manageProviders": "Manage Providers",
"modelSelector.manageProviders": "נהל ספקים",
"thinkingSelector.variant.default": "ברירת מחדל",
"thinkingSelector.label": "חשיבה: {variant}",
@ -74,10 +74,10 @@ export const settingsMessages = {
"settings.nav.notifications": "התראות",
"settings.nav.remote": "גישה מרוחקת",
"settings.nav.speech": "קול",
"settings.nav.providers": "Providers",
"settings.nav.providers": "ספקים",
"settings.nav.opencode": "OpenCode",
"settings.nav.configFiles": "Config Files",
"settings.nav.info": "Info",
"settings.nav.configFiles": "קובצי הגדרות",
"settings.nav.info": "מידע",
"settings.scope.device": "מכשיר זה",
"settings.scope.server": "הגדרת שרת",
"settings.common.enabled": "מופעל",
@ -132,83 +132,100 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "מידע",
"settings.opencode.logLevel.option.warn": "אזהרה",
"settings.opencode.logLevel.option.error": "שגיאה",
"settings.configFiles.title": "Config Files",
"settings.configFiles.subtitle": "Edit allowlisted global configuration files on this server.",
"settings.configFiles.selector.label": "File",
"settings.configFiles.selector.placeholder": "Select config file",
"settings.configFiles.actions.save": "Save",
"settings.configFiles.actions.reload": "Reload",
"settings.configFiles.state.unsaved": "Unsaved changes",
"settings.configFiles.state.notCreated": "File will be created on save",
"settings.configFiles.empty": "No config file selected.",
"settings.configFiles.errors.loadList": "Failed to load config files.",
"settings.configFiles.errors.loadContent": "Failed to load config file.",
"settings.configFiles.errors.save": "Failed to save config file.",
"settings.configFiles.toast.saveSuccess": "Config file saved.",
"settings.configFiles.toast.saveError": "Failed to save config file.",
"settings.configFiles.confirmDiscard.message": "Discard unsaved changes before continuing?",
"settings.configFiles.confirmDiscard.confirmLabel": "Discard changes",
"settings.configFiles.confirmDiscard.cancelLabel": "Keep editing",
"settings.configFiles.title": "קובצי הגדרות",
"settings.configFiles.subtitle": "ערוך קובצי הגדרות גלובליים שנמצאים ברשימת ההרשאות בשרת הזה.",
"settings.configFiles.selector.label": "קובץ",
"settings.configFiles.selector.placeholder": "בחר קובץ הגדרות",
"settings.configFiles.actions.save": "שמור",
"settings.configFiles.actions.reload": "טען מחדש",
"settings.configFiles.state.unsaved": "שינויים שלא נשמרו",
"settings.configFiles.state.notCreated": "הקובץ ייווצר בעת השמירה",
"settings.configFiles.empty": "לא נבחר קובץ הגדרות.",
"settings.configFiles.errors.loadList": "טעינת קובצי ההגדרות נכשלה.",
"settings.configFiles.errors.loadContent": "טעינת קובץ ההגדרות נכשלה.",
"settings.configFiles.errors.save": "שמירת קובץ ההגדרות נכשלה.",
"settings.configFiles.toast.saveSuccess": "קובץ ההגדרות נשמר.",
"settings.configFiles.toast.saveError": "שמירת קובץ ההגדרות נכשלה.",
"settings.configFiles.confirmDiscard.message": "לזרוק שינויים שלא נשמרו לפני שממשיכים?",
"settings.configFiles.confirmDiscard.confirmLabel": "זרוק שינויים",
"settings.configFiles.confirmDiscard.cancelLabel": "המשך לערוך",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.title": "אימות ספקים",
"settings.providers.subtitle": "חבר מפתחות API או חשבונות OAuth עבור מופע OpenCode הפעיל.",
"settings.providers.refresh": "רענן",
"settings.providers.selectProvider": "בחר ספק",
"settings.providers.available.title": "ספקים זמינים",
"settings.providers.configured.title": "ספקים מוגדרים",
"settings.providers.loading": "טוען שיטות אימות ספקים...",
"settings.providers.empty.noInstance": "הפעל סביבת עבודה של OpenCode לפני ניהול אימות ספקים.",
"settings.providers.empty.noProviders": "אין ספקים זמינים ממופע OpenCode הזה.",
"settings.providers.empty.noConfiguredProviders": "אין כרגע ספקים מוגדרים עבור מופע OpenCode הזה.",
"settings.providers.models.one": "{count} מודל",
"settings.providers.models.other": "{count} מודלים",
"settings.providers.actions.connect": "התחבר",
"settings.providers.actions.disconnect": "נתק",
"settings.providers.actions.remove": "הסר אימות שמור",
"settings.providers.actions.close": "סגור",
"settings.providers.actions.continue": "המשך",
"settings.providers.actions.working": "עובד...",
"settings.providers.method.title": "שיטת אימות",
"settings.providers.method.subtitle": "בחר את תהליך האימות ש-OpenCode חושף עבור הספק הזה.",
"settings.providers.method.api": "מפתח API",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.providers.method.mixed": "מפתח API ו-OAuth",
"settings.providers.method.fallback": "מפתח API",
"settings.providers.status.available": "זמין",
"settings.providers.auth.title": "התחבר אל {provider}",
"settings.providers.auth.subtitle": "האימות נשמר בתוך שרת OpenCode הפעיל.",
"settings.providers.apiKey.label": "מפתח API",
"settings.providers.apiKey.placeholder": "הדבק מפתח API של הספק",
"settings.providers.oauth.noPrompts": "שיטת OAuth זו אינה דורשת פרטים נוספים לפני פתיחת הדפדפן.",
"settings.providers.oauth.promptsUnsupported": "שיטת OAuth זו דורשת קלטים ייעודיים לספק שאינם נתמכים בגרסת ה-SDK הנוכחית.",
"settings.providers.oauth.enterCode": "השלם את התהליך בדפדפן, ואז הדבק את קוד ההרשאה.",
"settings.providers.oauth.openPage": "פתח דף הרשאה",
"settings.providers.oauth.openHere": "פתח בלשונית זו",
"settings.providers.oauth.copyLink": "העתק קישור",
"settings.providers.oauth.linkCopied": "הקישור הועתק",
"settings.providers.oauth.popupBlocked": "ייתכן שהדפדפן חסם את דף ההרשאה. השתמש בפעולות למעלה כדי להמשיך בתהליך ההתחברות.",
"settings.providers.oauth.popup.loadingTitle": "הרשאת OpenCode",
"settings.providers.oauth.popup.loadingBody": "מכין דף הרשאה...",
"settings.providers.oauth.codeLabel": "קוד הרשאה",
"settings.providers.oauth.codePlaceholder": "הדבק קוד מהספק",
"settings.providers.oauth.submitCode": "שלח קוד",
"settings.providers.oauth.waitingTitle": "ממתין להרשאה",
"settings.providers.oauth.cancelWait": "ביטול",
"settings.providers.prompt.selectPlaceholder": "בחר אפשרות",
"settings.providers.success": "אימות הספק נשמר. נתוני הספק רועננו.",
"settings.providers.source.api": "אימות ספק שמור",
"settings.providers.source.config": "מוגדר ב-opencode.jsonc",
"settings.providers.source.custom": "מוגדר על ידי לוגיקת ספק מותאמת אישית",
"settings.providers.source.env": "מוגדר ממשתני סביבה",
"settings.providers.source.unknown": "מקור ספק מוגדר",
"settings.providers.errors.loadFailed": "טעינת שיטות אימות הספקים נכשלה.",
"settings.providers.errors.noAuthorization": "הספק לא החזיר נתוני הרשאה.",
"settings.providers.errors.authorizationFailed": "ההרשאה נכשלה.",
"settings.providers.errors.removeFailed": "הסרת אימות הספק נכשלה.",
"settings.providers.errors.envDisconnectUnavailable": "ספק זה מוגדר ממשתני סביבה ולא ניתן לנתק אותו מהממשק.",
"settings.appearance.behavior.title": "אינטראקציה",
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
"settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets",
"settings.behavior.expansionPreset.minimal.title": "Minimal",
"settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.",
"settings.behavior.expansionPreset.balanced.title": "Balanced",
"settings.behavior.expansionPreset.balanced.description": "Show important actions while keeping noisy reads and fetches compact.",
"settings.behavior.expansionPreset.detailed.title": "Detailed",
"settings.behavior.expansionPreset.detailed.description": "Open most tool and thinking output for a fuller transcript.",
"settings.behavior.expansionPreset.everything.title": "Everything",
"settings.behavior.expansionPreset.everything.description": "Expand all tool and thinking output by default.",
"settings.behavior.expansionPreset.custom.title": "Custom",
"settings.behavior.expansionPreset.custom.description": "Your customized transcript detail defaults.",
"settings.behavior.expansionDefaults.title": "Expansion defaults",
"settings.behavior.expansionDefaults.itemColumn": "Item",
"settings.behavior.expansionDefaults.stateColumn": "Default state",
"settings.behavior.expansionDefaults.thinking": "thinking",
"settings.behavior.expansionDefaults.otherTools": "Other tools",
"settings.behavior.expansionDefaults.rowAriaLabel": "Default state for {item}",
"settings.behavior.keyboardHints.title": "רמזי קיצורי מקלדת",
"settings.behavior.keyboardHints.subtitle": "הצג רמזי קיצורי מקלדת בכל הממשק.",
"settings.behavior.thinking.title": "קטעי חשיבה",
@ -286,41 +303,41 @@ export const settingsMessages = {
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
"settings.speech.save.error": "השמירה נכשלה",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.eyebrow": "שירותי שרת",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"settings.section.sidecars.subtitle": "הגדר שירותים מקומיים שמאזינים בפורטים קבועים, כדי שהשרת יוכל להעביר אותם ללשוניות דרך proxy.",
"sidecars.form.name": "שם",
"sidecars.form.validation": "הזן שם SideCar ופורט תקינים.",
"sidecars.form.port": "פורט",
"sidecars.form.insecure": "השתמש ב-HTTP",
"sidecars.form.protocol": "פרוטוקול",
"sidecars.form.protocol.help": "בחר כיצד ה-proxy יתחבר לשירות המקומי.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
"sidecars.form.prefixMode": "מצב קידומת",
"sidecars.form.prefixMode.help": "בחר אם ה-SideCar יקבל את קידומת הנתיב הציבורית /sidecars.",
"sidecars.form.prefixMode.strip": "הסר קידומת",
"sidecars.form.prefixMode.preserve": "שמור קידומת",
"sidecars.form.add": "הוסף SideCar",
"sidecars.kind.port": "פורט",
"sidecars.status.running": "רץ",
"sidecars.status.stopped": "עצור",
"sidecars.basePath": "נתיב בסיס",
"sidecars.settings.listTitle": "SideCars מוגדרים",
"sidecars.settings.listSubtitle": "סקור את ה-SideCars מבוססי הפורט הזמינים בבורר.",
"sidecars.settings.empty": "עדיין לא הוגדרו SideCars.",
"sidecars.picker.title": "פתח SideCar",
"sidecars.picker.loading": "טוען SideCars...",
"sidecars.picker.subtitle": "בחר SideCar זמין לפתיחה בלשונית חדשה.",
"sidecars.picker.empty": "עדיין אין SideCars מבוססי פורט זמינים.",
"sidecars.picker.close": "סגור",
"sidecars.open.errorTitle": "לא ניתן לפתוח SideCar",
"sidecars.open.notFound": "SideCar לא נמצא.",
"sidecars.open.notRunning": "לא ניתן להגיע ל-SideCar בפורט שהוגדר לו.",
"sidecars.back": "חזרה",
"sidecars.refresh": "רענן",
"sidecars.path": "נתיב",
"sidecars.go": "עבור",
// Toast History Panel
"toastHistory.title": "היסטוריית התראות",
@ -343,25 +360,25 @@ export const settingsMessages = {
"toastHistory.deleteItem": "מחק התראה",
// Info Section
"settings.section.info.title": "About",
"settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.",
"settings.info.version.server": "Server version",
"settings.info.version.ui": "UI version",
"settings.info.version.uiSource": "UI source",
"settings.info.runtime.type": "Runtime",
"settings.info.runtime.platform": "Platform",
"settings.info.runtime.os": "Operating system",
"settings.info.server.url": "Server URL",
"settings.info.server.root": "Workspace root",
"settings.info.updates.title": "Updates",
"settings.info.updates.subtitle": "Check for new releases of CodeNomad.",
"settings.info.updates.latest": "Latest version",
"settings.info.updates.download": "Download update",
"settings.info.updates.refresh": "Refresh",
"settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.",
"settings.info.diagnostics.title": "Diagnostics",
"settings.section.info.title": "אודות",
"settings.section.info.subtitle": "צפה בגרסה ובסביבת הריצה, ואסוף מידע אבחון.",
"settings.info.version.server": "גרסת שרת",
"settings.info.version.ui": "גרסת ממשק",
"settings.info.version.uiSource": "מקור הממשק",
"settings.info.runtime.type": "סביבת ריצה",
"settings.info.runtime.platform": "פלטפורמה",
"settings.info.runtime.os": "מערכת הפעלה",
"settings.info.server.url": "כתובת השרת",
"settings.info.server.root": "שורש סביבת העבודה",
"settings.info.updates.title": "עדכונים",
"settings.info.updates.subtitle": "בדוק אם יש גרסאות חדשות של CodeNomad.",
"settings.info.updates.latest": "הגרסה האחרונה",
"settings.info.updates.download": "הורד עדכון",
"settings.info.updates.refresh": "רענן",
"settings.info.diagnostics.copyFailed": "הגישה ללוח נדחתה. השתמש בכפתור ההורדה במקום.",
"settings.info.diagnostics.title": "אבחון",
"settings.info.diagnostics.subtitle": "אסוף מידע על המערכת והגרסה לדיווחי באגים.",
"settings.info.diagnostics.copy": "Copy to clipboard",
"settings.info.diagnostics.download": "Download .txt",
"settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.",
"settings.info.diagnostics.copy": "העתק ללוח",
"settings.info.diagnostics.download": "הורד .txt",
"settings.info.diagnostics.copied": "מידע האבחון הועתק ללוח.",
} as const

View file

@ -4,6 +4,8 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "העתק כותרת קריאת כלי",
"toolCall.header.copyAriaLabel": "העתק כותרת קריאת כלי",
"toolCall.header.copyOutputTitle": "העתק פלט כלי",
"toolCall.header.copyOutputAriaLabel": "העתק פלט כלי",
"toolCall.header.showInputTitle": "הצג ארגומנטי כלי",
"toolCall.header.showInputAriaLabel": "הצג ארגומנטי כלי",
@ -12,6 +14,10 @@ export const toolCallMessages = {
"toolCall.io.input": "קלט כלי",
"toolCall.io.output": "פלט כלי",
"toolCall.io.copyInputTitle": "העתק קלט כלי",
"toolCall.io.copyInputAriaLabel": "העתק קלט כלי",
"toolCall.io.copyOutputTitle": "העתק פלט כלי",
"toolCall.io.copyOutputAriaLabel": "העתק פלט כלי",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
@ -53,7 +59,7 @@ export const toolCallMessages = {
"toolCall.renderer.read.detail.offset": "היסט: {offset}",
"toolCall.renderer.read.detail.limit": "מגבלה: {limit}",
"toolCall.renderer.todo.empty": "אין פריטי תוכנית עדיין.",
"toolCall.renderer.todo.empty": "אין פריטים מתוכננים",
"toolCall.renderer.todo.status.pending": "ממתין",
"toolCall.renderer.todo.status.inProgress": "בביצוע",
"toolCall.renderer.todo.status.completed": "הושלם",
@ -71,13 +77,9 @@ export const toolCallMessages = {
"toolCall.permission.actions.allowOnce": "אפשר פעם אחת",
"toolCall.permission.actions.alwaysAllow": "אפשר תמיד",
"toolCall.permission.actions.deny": "דחה",
"toolCall.permission.actions.confirmDeny": "אשר דחייה",
"toolCall.permission.actions.cancel": "בטל",
"toolCall.permission.shortcuts.allowOnce": "אפשר פעם אחת",
"toolCall.permission.shortcuts.alwaysAllow": "אפשר תמיד",
"toolCall.permission.rejectReason.label": "למה לדחות את האישור הזה?",
"toolCall.permission.rejectReason.placeholder": "ספר ל-CodeNomad מה לעשות אחרת...",
"toolCall.permission.rejectReason.hint": "הסוכן יראה את המשוב הזה ויוכל להתאים את הפעולה הבאה שלו.",
"toolCall.permission.rejectReason.placeholder": "משוב אופציונלי בעת דחייה, למשל השתמש בפקודה בטוחה יותר...",
"toolCall.permission.errors.unableToUpdate": "לא ניתן לעדכן אישור",
"permissionApproval.title": "בקשות",
@ -94,11 +96,7 @@ export const toolCallMessages = {
"permissionApproval.actions.allowOnce": "אפשר פעם אחת",
"permissionApproval.actions.alwaysAllow": "אפשר תמיד",
"permissionApproval.actions.deny": "דחה",
"permissionApproval.actions.confirmDeny": "אשר דחייה",
"permissionApproval.actions.cancel": "בטל",
"permissionApproval.rejectReason.label": "למה לדחות את האישור הזה?",
"permissionApproval.rejectReason.placeholder": "ספר ל-CodeNomad מה לעשות אחרת...",
"permissionApproval.rejectReason.hint": "הסוכן יראה את המשוב הזה ויוכל להתאים את הפעולה הבאה שלו.",
"permissionApproval.rejectReason.placeholder": "משוב אופציונלי בעת דחייה, למשל השתמש בפקודה בטוחה יותר...",
"permissionApproval.fallbackHint": "טען סשן לקבלת מידע נוסף.",
"permissionApproval.errors.unableToUpdatePermission": "לא ניתן לעדכן אישור",

View file

@ -12,6 +12,7 @@ export const appMessages = {
"app.stopInstance.title": "インスタンスを停止",
"app.stopInstance.confirmLabel": "停止",
"app.stopInstance.cancelLabel": "実行を続ける",
"app.stopInstance.toast.error": "ワークスペースの停止に失敗しました。",
"emptyState.logoAlt": "CodeNomad ロゴ",
"emptyState.brandTitle": "CodeNomad",
@ -33,4 +34,10 @@ export const appMessages = {
"releases.devUpdateAvailable.title": "開発版が利用可能",
"releases.devUpdateAvailable.message": "新しい開発版が利用可能です: {version}。",
"releases.devUpdateAvailable.action": "リリースを見る",
"theme.mode.system": "システム",
"theme.mode.light": "ライト",
"theme.mode.dark": "ダーク",
"theme.toggle.title": "テーマ: {mode}",
"theme.toggle.ariaLabel": "テーマ: {mode}",
} as const

View file

@ -168,6 +168,7 @@ export const commandMessages = {
"unifiedPicker.sections.commands": "コマンド",
"unifiedPicker.sections.agents": "エージェント",
"unifiedPicker.sections.files": "ファイル",
"unifiedPicker.sections.workspaceRoot": "ワークスペースルート",
"unifiedPicker.badge.subagent": "サブエージェント",
"unifiedPicker.footer.navigate": "移動",
"unifiedPicker.footer.select": "選択",

View file

@ -95,7 +95,7 @@ export const folderSelectionMessages = {
"folderSelection.servers.certificateInstall.confirmLabel": "続行",
"folderSelection.servers.certificateInstall.cancelLabel": "キャンセル",
"folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。",
"folderSelection.sidecars.button": "Open SideCar",
"folderSelection.sidecars.button": "SideCar を開く",
"projectRenameDialog.title": "ワークスペース名を変更",
"projectRenameDialog.description.withLabel": "\"{label}\" のワークスペース名を更新します。",

View file

@ -119,6 +119,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.sections.plugins": "プラグイン",
"instanceShell.rightPanel.sections.plugins.tooltip": "UI とサーバーの動作をカスタマイズし、MCP や LSP 以外の機能も追加できるプラグイン。",
"instanceShell.gitChanges.noSessionSelected": "Git の変更を表示するにはセッションを選択してください。",
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
"instanceShell.gitChanges.deleted": "削除済み",
@ -141,6 +142,8 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerPlaceholder": "詳細な変更表示は次のステップで追加します。",
"instanceShell.filesShell.viewerEmpty": "ファイルが選択されていません。",
"instanceShell.filesShell.listEmpty": "このフォルダーにファイルはありません。",
"instanceShell.filesShell.hideFiles": "ファイルを非表示",
"instanceShell.filesShell.showFiles": "ファイルを表示",
"instanceShell.filesShell.search.placeholder": "このフォルダーのファイルを絞り込む",
"instanceShell.filesShell.search.ariaLabel": "このフォルダーのファイルを絞り込む",
"instanceShell.filesShell.search.empty": "一致するファイルがありません。",
@ -151,6 +154,13 @@ export const instanceMessages = {
"instanceShell.filesShell.showSource": "ソースを表示",
"instanceShell.filesShell.enableWordWrap": "折り返しを有効化",
"instanceShell.filesShell.disableWordWrap": "折り返しを無効化",
"instanceShell.diff.hideUnchanged": "未変更部分を非表示",
"instanceShell.diff.showFull": "ファイル全体を表示",
"instanceShell.diff.switchToSplit": "分割表示に切り替え",
"instanceShell.diff.switchToUnified": "ユニファイド表示に切り替え",
"instanceShell.diff.enableWordWrap": "折り返しを有効化",
"instanceShell.diff.disableWordWrap": "折り返しを無効化",
"instanceShell.worktree.create": "+ worktree を作成",
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
"instanceShell.plan.empty": "まだ計画はありません。",
@ -212,6 +222,6 @@ export const instanceMessages = {
"opencodeBinarySelector.dialog.description": "CLI サーバーから公開されているファイルを参照します。",
"opencodeBinarySelector.validation.invalidBinary": "OpenCode バイナリが無効です",
"opencodeBinarySelector.validation.alreadyValidating": "すでに検証中です",
"opencodeBinarySelector.display.systemPath": "{name} (system PATH)",
"opencodeBinarySelector.display.systemPath": "{name} (システム PATH)",
"opencodeBinarySelector.versionLabel": "v{version}",
} as const

View file

@ -20,6 +20,7 @@ export const messagingMessages = {
"messageSection.loadError.reload": "メッセージを再読み込み",
"messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール",
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
"messageSection.scroll.showControlsAriaLabel": "スクロール操作を表示",
"messageSection.scroll.enableHoldAriaLabel": "長いアシスタント返信の保持を有効にする",
"messageSection.scroll.disableHoldAriaLabel": "長いアシスタント返信の保持を無効にする",
"messageSection.quote.addAsQuote": "引用として追加",
@ -80,6 +81,15 @@ export const messagingMessages = {
"messageBlock.reasoning.indicator.hide": "非表示",
"messageBlock.reasoning.indicator.view": "表示",
"messageBlock.reasoning.detailsAriaLabel": "推論の詳細",
"messageBlock.reasoning.thoughtsFallback": "思考",
"messageBlock.reasoning.thoughtFor.seconds.one": "{count} 秒間思考",
"messageBlock.reasoning.thoughtFor.seconds.other": "{count} 秒間思考",
"messageBlock.reasoning.thoughtFor.minutes.one": "{count} 分間思考",
"messageBlock.reasoning.thoughtFor.minutes.other": "{count} 分間思考",
"messageBlock.reasoning.thoughtFor.hours.one": "{count} 時間思考",
"messageBlock.reasoning.thoughtFor.hours.other": "{count} 時間思考",
"messageBlock.reasoning.copyTitle": "思考をコピー",
"messageBlock.reasoning.copyAriaLabel": "思考をコピー",
"codeBlockInline.actions.copy": "コピー",
"codeBlockInline.actions.copied": "コピーしました!",
@ -108,6 +118,14 @@ export const messagingMessages = {
"messageItem.actions.deleteMessageFailedMessage": "メッセージの削除に失敗しました",
"messageItem.selection.checkboxAriaLabel": "削除するメッセージを選択",
"messageItem.selection.selectForDeletion": "削除するメッセージを選択",
"messageItem.selection.deselectForDeletion": "削除対象のメッセージ選択を解除",
"messageItem.duration.workedFor.seconds.one": "{count}秒間処理しました",
"messageItem.duration.workedFor.seconds.other": "{count}秒間処理しました",
"messageItem.duration.workedFor.minutes.one": "{count}分間処理しました",
"messageItem.duration.workedFor.minutes.other": "{count}分間処理しました",
"messageItem.duration.workedFor.hours.one": "{count}時間処理しました",
"messageItem.duration.workedFor.hours.other": "{count}時間処理しました",
"messageSection.bulkDelete.toolbarAriaLabel": "選択した項目({count}",
"messageSection.bulkDelete.deleteSelectedTitle": "選択した項目を削除",

View file

@ -32,7 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "すべてのモデルを表示",
"modelSelector.favorite.add": "お気に入りに追加",
"modelSelector.favorite.remove": "お気に入りから削除",
"modelSelector.manageProviders": "Manage Providers",
"modelSelector.manageProviders": "プロバイダーを管理",
"thinkingSelector.variant.default": "デフォルト",
"thinkingSelector.label": "思考: {variant}",
@ -64,66 +64,66 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "残り",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.providers": "Providers",
"settings.title": "設定",
"settings.navigationAriaLabel": "設定セクション",
"settings.close": "設定を閉じる",
"settings.content.eyebrow": "ワークスペース設定",
"settings.open.title": "設定を開く",
"settings.open.ariaLabel": "設定を開く",
"settings.nav.appearance": "外観",
"settings.nav.notifications": "通知",
"settings.nav.remote": "リモートアクセス",
"settings.nav.speech": "音声",
"settings.nav.providers": "プロバイダー",
"settings.nav.opencode": "OpenCode",
"settings.nav.configFiles": "Config Files",
"settings.nav.info": "Info",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.nav.configFiles": "設定ファイル",
"settings.nav.info": "情報",
"settings.scope.device": "このデバイス",
"settings.scope.server": "サーバー設定",
"settings.common.enabled": "有効",
"settings.common.disabled": "無効",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.appearance.title": "外観",
"settings.section.appearance.subtitle": "このデバイスでのアプリの見た目を調整します。",
"settings.appearance.theme.title": "テーマ",
"settings.appearance.theme.subtitle": "アプリ全体で使うカラーモードを選択します。",
"settings.appearance.theme.option.system": "OS の設定に合わせる",
"settings.appearance.theme.option.light": "ライト表示を使用",
"settings.appearance.theme.option.dark": "ダーク表示を使用",
"settings.section.notifications.title": "通知",
"settings.section.notifications.subtitle": "セッションの動作に関する OS レベルの通知を制御します。",
"settings.notifications.permission.granted": "許可済み",
"settings.notifications.permission.denied": "拒否済み",
"settings.notifications.permission.default": "未許可",
"settings.notifications.permission.unsupported": "未対応",
"settings.notifications.messages.unsupportedEnvironment": "この環境では OS 通知はサポートされていません。",
"settings.notifications.messages.permissionDenied": "通知権限が拒否されています。システムまたはブラウザー設定で通知を有効にしてください。",
"settings.notifications.messages.permissionNotGranted": "通知権限が許可されていません。",
"settings.notifications.messages.unsupportedGeneral": "この環境では通知はサポートされていません。",
"settings.notifications.messages.permissionGranted": "権限が許可されました。通知を有効にできます。",
"settings.notifications.messages.permissionRequestDenied": "権限が拒否されました。システムまたはブラウザー設定で通知を有効にする必要がある場合があります。",
"settings.notifications.sessionStatus.title": "セッション状態の通知",
"settings.notifications.sessionStatus.subtitle": "セッションに対応が必要なときに通知を受け取ります。",
"settings.notifications.enable.title": "通知を有効化",
"settings.notifications.enable.permission": "権限: {permission}",
"settings.notifications.requestPermission.title": "権限をリクエスト",
"settings.notifications.requestPermission.subtitle": "このデバイスでアプリから通知を送信できるようにします。",
"settings.notifications.requestPermission.action": "リクエスト",
"settings.notifications.allowVisible.title": "アプリにフォーカスがあるときも通知",
"settings.notifications.allowVisible.subtitle": "このウィンドウが表示中でも通知を有効のままにします。",
"settings.notifications.unsupportedNote": "この環境では通知はサポートされていません。通知コントロールは無効のままです。",
"settings.notifications.events.title": "通知するタイミング",
"settings.notifications.events.subtitle": "通知を送信するセッションイベントを選択します。",
"settings.notifications.events.needsInput": "セッションに入力が必要",
"settings.notifications.events.idle": "セッションが待機状態になる",
"settings.notifications.status.enabled": "通知は有効です",
"settings.notifications.status.disabled": "通知は無効です",
"settings.notifications.status.unsupported": "通知は未対応です",
"settings.section.remote.title": "リモートアクセス",
"settings.section.remote.subtitle": "このサーバーがネットワーク上でどのように公開されているかと、アクセス認証情報を確認します。",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.section.opencode.subtitle": "新しいインスタンスで使う OpenCode バイナリと環境を選択します。",
"settings.opencode.runtime.title": "ランタイム",
"settings.opencode.runtime.subtitle": "新しいインスタンスの起動に使う OpenCode バイナリを設定します。",
"settings.opencode.logLevel.title": "OpenCode のログレベル",
"settings.opencode.logLevel.subtitle": "新しい OpenCode インスタンスの起動時に使うログレベルを設定します。",
"settings.opencode.logLevel.selector.title": "ログ出力の詳細度",
@ -132,83 +132,100 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.info": "情報",
"settings.opencode.logLevel.option.warn": "警告",
"settings.opencode.logLevel.option.error": "エラー",
"settings.configFiles.title": "Config Files",
"settings.configFiles.subtitle": "Edit allowlisted global configuration files on this server.",
"settings.configFiles.selector.label": "File",
"settings.configFiles.selector.placeholder": "Select config file",
"settings.configFiles.actions.save": "Save",
"settings.configFiles.actions.reload": "Reload",
"settings.configFiles.state.unsaved": "Unsaved changes",
"settings.configFiles.state.notCreated": "File will be created on save",
"settings.configFiles.empty": "No config file selected.",
"settings.configFiles.errors.loadList": "Failed to load config files.",
"settings.configFiles.errors.loadContent": "Failed to load config file.",
"settings.configFiles.errors.save": "Failed to save config file.",
"settings.configFiles.toast.saveSuccess": "Config file saved.",
"settings.configFiles.toast.saveError": "Failed to save config file.",
"settings.configFiles.confirmDiscard.message": "Discard unsaved changes before continuing?",
"settings.configFiles.confirmDiscard.confirmLabel": "Discard changes",
"settings.configFiles.confirmDiscard.cancelLabel": "Keep editing",
"settings.configFiles.title": "設定ファイル",
"settings.configFiles.subtitle": "このサーバーで許可リストに登録されたグローバル設定ファイルを編集します。",
"settings.configFiles.selector.label": "ファイル",
"settings.configFiles.selector.placeholder": "設定ファイルを選択",
"settings.configFiles.actions.save": "保存",
"settings.configFiles.actions.reload": "再読み込み",
"settings.configFiles.state.unsaved": "未保存の変更",
"settings.configFiles.state.notCreated": "保存時にファイルが作成されます",
"settings.configFiles.empty": "設定ファイルが選択されていません。",
"settings.configFiles.errors.loadList": "設定ファイルの読み込みに失敗しました。",
"settings.configFiles.errors.loadContent": "設定ファイルの内容を読み込めませんでした。",
"settings.configFiles.errors.save": "設定ファイルの保存に失敗しました。",
"settings.configFiles.toast.saveSuccess": "設定ファイルを保存しました。",
"settings.configFiles.toast.saveError": "設定ファイルの保存に失敗しました。",
"settings.configFiles.confirmDiscard.message": "続行する前に未保存の変更を破棄しますか?",
"settings.configFiles.confirmDiscard.confirmLabel": "変更を破棄",
"settings.configFiles.confirmDiscard.cancelLabel": "編集を続ける",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.title": "プロバイダー認証",
"settings.providers.subtitle": "アクティブな OpenCode インスタンスで使う API キーまたは OAuth アカウントを接続します。",
"settings.providers.refresh": "更新",
"settings.providers.selectProvider": "プロバイダーを選択",
"settings.providers.available.title": "利用可能なプロバイダー",
"settings.providers.configured.title": "設定済みプロバイダー",
"settings.providers.loading": "プロバイダー認証方法を読み込み中...",
"settings.providers.empty.noInstance": "プロバイダー認証を管理する前に OpenCode ワークスペースを開始してください。",
"settings.providers.empty.noProviders": "この OpenCode インスタンスで利用可能なプロバイダーはありません。",
"settings.providers.empty.noConfiguredProviders": "この OpenCode インスタンスには現在設定済みのプロバイダーがありません。",
"settings.providers.models.one": "{count} 個のモデル",
"settings.providers.models.other": "{count} 個のモデル",
"settings.providers.actions.connect": "接続",
"settings.providers.actions.disconnect": "切断",
"settings.providers.actions.remove": "保存済み認証を削除",
"settings.providers.actions.close": "閉じる",
"settings.providers.actions.continue": "続行",
"settings.providers.actions.working": "処理中...",
"settings.providers.method.title": "認証方法",
"settings.providers.method.subtitle": "このプロバイダーで OpenCode が提供するフローを選択します。",
"settings.providers.method.api": "API キー",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.providers.method.mixed": "API キーと OAuth",
"settings.providers.method.fallback": "API キー",
"settings.providers.status.available": "利用可能",
"settings.providers.auth.title": "{provider} に接続",
"settings.providers.auth.subtitle": "認証情報はアクティブな OpenCode サーバー内に保存されます。",
"settings.providers.apiKey.label": "API キー",
"settings.providers.apiKey.placeholder": "プロバイダーの API キーを貼り付け",
"settings.providers.oauth.noPrompts": "この OAuth 方法では、ブラウザーを開く前に追加情報は不要です。",
"settings.providers.oauth.promptsUnsupported": "この OAuth 方法にはプロバイダー固有の入力が必要ですが、現在の SDK ビルドではサポートされていません。",
"settings.providers.oauth.enterCode": "ブラウザーでのフローを完了してから、認可コードを貼り付けてください。",
"settings.providers.oauth.openPage": "認可ページを開く",
"settings.providers.oauth.openHere": "このタブで開く",
"settings.providers.oauth.copyLink": "リンクをコピー",
"settings.providers.oauth.linkCopied": "リンクをコピーしました",
"settings.providers.oauth.popupBlocked": "ブラウザーが認可ページをブロックした可能性があります。上の操作でサインインフローを続行してください。",
"settings.providers.oauth.popup.loadingTitle": "OpenCode 認可",
"settings.providers.oauth.popup.loadingBody": "認可ページを準備中...",
"settings.providers.oauth.codeLabel": "認可コード",
"settings.providers.oauth.codePlaceholder": "プロバイダーからのコードを貼り付け",
"settings.providers.oauth.submitCode": "コードを送信",
"settings.providers.oauth.waitingTitle": "認可を待機中",
"settings.providers.oauth.cancelWait": "キャンセル",
"settings.providers.prompt.selectPlaceholder": "オプションを選択",
"settings.providers.success": "プロバイダー認証を保存しました。プロバイダーデータを更新しました。",
"settings.providers.source.api": "保存済みプロバイダー認証",
"settings.providers.source.config": "opencode.jsonc で設定済み",
"settings.providers.source.custom": "カスタムプロバイダーロジックで設定済み",
"settings.providers.source.env": "環境変数から設定済み",
"settings.providers.source.unknown": "設定済みプロバイダーソース",
"settings.providers.errors.loadFailed": "プロバイダー認証方法の読み込みに失敗しました。",
"settings.providers.errors.noAuthorization": "プロバイダーが認可データを返しませんでした。",
"settings.providers.errors.authorizationFailed": "認可に失敗しました。",
"settings.providers.errors.removeFailed": "プロバイダー認証の削除に失敗しました。",
"settings.providers.errors.envDisconnectUnavailable": "このプロバイダーは環境変数から設定されているため、UI から切断できません。",
"settings.appearance.behavior.title": "操作",
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
"settings.behavior.expansionPresets.ariaLabel": "Transcript detail presets",
"settings.behavior.expansionPreset.minimal.title": "Minimal",
"settings.behavior.expansionPreset.minimal.description": "Keep most generated detail tucked away until needed.",
"settings.behavior.expansionPreset.balanced.title": "Balanced",
"settings.behavior.expansionPreset.balanced.description": "Show important actions while keeping noisy reads and fetches compact.",
"settings.behavior.expansionPreset.detailed.title": "Detailed",
"settings.behavior.expansionPreset.detailed.description": "Open most tool and thinking output for a fuller transcript.",
"settings.behavior.expansionPreset.everything.title": "Everything",
"settings.behavior.expansionPreset.everything.description": "Expand all tool and thinking output by default.",
"settings.behavior.expansionPreset.custom.title": "Custom",
"settings.behavior.expansionPreset.custom.description": "Your customized transcript detail defaults.",
"settings.behavior.expansionDefaults.title": "Expansion defaults",
"settings.behavior.expansionDefaults.itemColumn": "Item",
"settings.behavior.expansionDefaults.stateColumn": "Default state",
"settings.behavior.expansionDefaults.thinking": "thinking",
"settings.behavior.expansionDefaults.otherTools": "Other tools",
"settings.behavior.expansionDefaults.rowAriaLabel": "Default state for {item}",
"settings.behavior.keyboardHints.title": "キーボードショートカットのヒント",
"settings.behavior.keyboardHints.subtitle": "UI全体でキーボードショートカットのヒントを表示します。",
"settings.behavior.thinking.title": "思考セクション",
@ -237,26 +254,26 @@ export const settingsMessages = {
"settings.behavior.keepUnseenSubagentIdle.subtitle": "サブエージェントの idle マーカーを 5 秒後に隠さず、表示するまで残します。",
"settings.behavior.tauriNativeEventTransport.title": "Tauri ネイティブイベント転送",
"settings.behavior.tauriNativeEventTransport.subtitle": "Tauri で Rust ネイティブのデスクトップイベント転送を使います。無効にすると browser EventSource 経路に戻ります。",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptVoiceInput.title": "プロンプト音声入力",
"settings.behavior.promptVoiceInput.subtitle": "音声が設定されている場合、音声文字起こし用のマイク操作を表示します。",
"settings.behavior.promptSubmit.title": "Enterで送信",
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
"settings.speech.title": "音声",
"settings.speech.subtitle": "今すぐ音声入力を設定し、今後の機能のために音声合成の基盤も準備します。",
"settings.speech.provider.title": "プロバイダー",
"settings.speech.provider.subtitle": "音声リクエストはサーバー側の音声アダプターを使用します。",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.provider.openaiCompatible": "OpenAI 互換",
"settings.speech.status.loading": "設定を確認しています...",
"settings.speech.status.configured": "設定済み",
"settings.speech.status.missing": "APIキーがありません",
"settings.speech.status.error": "音声サービスを利用できません",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.title": "API キー",
"settings.speech.apiKey.subtitle": "CodeNomadが管理する音声リクエストに使用されます。",
"settings.speech.apiKey.placeholder": "新しいAPIキーを入力",
"settings.speech.apiKey.storedNote": "保存済みのAPIキーは非表示になっています。置き換えるには新しい値を入力し、そのまま使うには空欄のままにしてください。",
"settings.speech.apiKey.clearAction": "保存済みキーを削除",
"settings.speech.apiKey.clearPending": "保存すると、保存済みのAPIキーは削除されます。",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.title": "ベース URL",
"settings.speech.baseUrl.subtitle": "OpenAI互換の音声エンドポイント用の任意の上書き設定です。",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "文字起こしモデル",
@ -267,8 +284,8 @@ export const settingsMessages = {
"settings.speech.ttsVoice.subtitle": "将来の再生機能のために予約されている既定の音声合成ボイスです。",
"settings.speech.playbackMode.title": "再生モード",
"settings.speech.playbackMode.subtitle": "音声が届き次第再生を始めるか、ファイル全体の生成後に再生するかを選択します。",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.playbackMode.streaming": "ストリーミング",
"settings.speech.playbackMode.buffered": "バッファ再生",
"settings.speech.ttsFormat.title": "出力形式",
"settings.speech.ttsFormat.subtitle": "音声合成の出力形式を選択します。ストリーミング対応はプロバイダーとブラウザーに依存します。",
"settings.speech.help": "プロンプト音声入力は音声文字起こしが設定され対応している場合に表示されます。メッセージ再生にはここで選んだTTSモードと形式が使われます。",
@ -285,42 +302,42 @@ export const settingsMessages = {
"settings.speech.save.saved": "保存済み",
"settings.speech.save.unsaved": "未保存の変更",
"settings.speech.save.error": "保存に失敗しました",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"settings.nav.sidecars": "SideCar",
"settings.section.sidecars.eyebrow": "サーバーサービス",
"settings.section.sidecars.title": "SideCar",
"settings.section.sidecars.subtitle": "サーバーがタブへプロキシできる、固定ポートで待ち受けるローカルサービスを設定します。",
"sidecars.form.name": "名前",
"sidecars.form.validation": "有効な SideCar 名とポートを入力してください。",
"sidecars.form.port": "ポート",
"sidecars.form.insecure": "HTTP を使用",
"sidecars.form.protocol": "プロトコル",
"sidecars.form.protocol.help": "プロキシがローカルサービスへ接続する方法を選択します。",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
"sidecars.form.prefixMode": "プレフィックスモード",
"sidecars.form.prefixMode.help": "SideCar が公開 /sidecars パスプレフィックスを受け取るかを選択します。",
"sidecars.form.prefixMode.strip": "プレフィックスを削除",
"sidecars.form.prefixMode.preserve": "プレフィックスを保持",
"sidecars.form.add": "SideCar を追加",
"sidecars.kind.port": "ポート",
"sidecars.status.running": "実行中",
"sidecars.status.stopped": "停止中",
"sidecars.basePath": "ベースパス",
"sidecars.settings.listTitle": "設定済み SideCar",
"sidecars.settings.listSubtitle": "ピッカーで利用可能なポートベースの SideCar を確認します。",
"sidecars.settings.empty": "SideCar はまだ設定されていません。",
"sidecars.picker.title": "SideCar を開く",
"sidecars.picker.loading": "SideCar を読み込み中...",
"sidecars.picker.subtitle": "新しいタブで開く利用可能な SideCar を選択します。",
"sidecars.picker.empty": "利用可能なポートベースの SideCar はまだありません。",
"sidecars.picker.close": "閉じる",
"sidecars.open.errorTitle": "SideCar を開けません",
"sidecars.open.notFound": "SideCar が見つかりません。",
"sidecars.open.notRunning": "SideCar は設定されたポートで到達できません。",
"sidecars.back": "戻る",
"sidecars.refresh": "更新",
"sidecars.path": "パス",
"sidecars.go": "移動",
// Toast History Panel
"toastHistory.title": "通知履歴",
@ -343,25 +360,25 @@ export const settingsMessages = {
"toastHistory.deleteItem": "通知を削除",
// Info Section
"settings.section.info.title": "About",
"settings.section.info.subtitle": "View version, runtime, and gather diagnostic information.",
"settings.info.version.server": "Server version",
"settings.info.version.ui": "UI version",
"settings.info.version.uiSource": "UI source",
"settings.info.runtime.type": "Runtime",
"settings.info.runtime.platform": "Platform",
"settings.info.runtime.os": "Operating system",
"settings.info.server.url": "Server URL",
"settings.info.server.root": "Workspace root",
"settings.info.updates.title": "Updates",
"settings.info.updates.subtitle": "Check for new releases of CodeNomad.",
"settings.info.updates.latest": "Latest version",
"settings.info.updates.download": "Download update",
"settings.info.updates.refresh": "Refresh",
"settings.info.diagnostics.copyFailed": "Clipboard access denied. Use the download button instead.",
"settings.info.diagnostics.title": "Diagnostics",
"settings.section.info.title": "概要",
"settings.section.info.subtitle": "バージョン、ランタイムを確認し、診断情報を収集します。",
"settings.info.version.server": "サーバーバージョン",
"settings.info.version.ui": "UI バージョン",
"settings.info.version.uiSource": "UI ソース",
"settings.info.runtime.type": "ランタイム",
"settings.info.runtime.platform": "プラットフォーム",
"settings.info.runtime.os": "オペレーティングシステム",
"settings.info.server.url": "サーバー URL",
"settings.info.server.root": "ワークスペースルート",
"settings.info.updates.title": "更新",
"settings.info.updates.subtitle": "CodeNomad の新しいリリースを確認します。",
"settings.info.updates.latest": "最新バージョン",
"settings.info.updates.download": "更新をダウンロード",
"settings.info.updates.refresh": "更新",
"settings.info.diagnostics.copyFailed": "クリップボードへのアクセスが拒否されました。代わりにダウンロードボタンを使用してください。",
"settings.info.diagnostics.title": "診断",
"settings.info.diagnostics.subtitle": "バグ報告用にシステム情報とバージョン情報を収集します。",
"settings.info.diagnostics.copy": "Copy to clipboard",
"settings.info.diagnostics.download": "Download .txt",
"settings.info.diagnostics.copied": "Diagnostic info copied to clipboard.",
"settings.info.diagnostics.copy": "クリップボードにコピー",
"settings.info.diagnostics.download": ".txt をダウンロード",
"settings.info.diagnostics.copied": "診断情報をクリップボードにコピーしました。",
} as const

View file

@ -2,19 +2,25 @@ export const toolCallMessages = {
"toolCall.pending.waitingToRun": "実行待ち...",
"toolCall.error.label": "エラー:",
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.copyTitle": "ツール呼び出しのタイトルをコピー",
"toolCall.header.copyAriaLabel": "ツール呼び出しのタイトルをコピー",
"toolCall.header.copyOutputTitle": "ツール出力をコピー",
"toolCall.header.copyOutputAriaLabel": "ツール出力をコピー",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.header.showInputTitle": "ツール引数を表示",
"toolCall.header.showInputAriaLabel": "ツール引数を表示",
"toolCall.header.hideInputTitle": "ツール引数を非表示",
"toolCall.header.hideInputAriaLabel": "ツール引数を非表示",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.io.input": "ツール入力",
"toolCall.io.output": "ツール出力",
"toolCall.io.copyInputTitle": "ツール入力をコピー",
"toolCall.io.copyInputAriaLabel": "ツール入力をコピー",
"toolCall.io.copyOutputTitle": "ツール出力をコピー",
"toolCall.io.copyOutputAriaLabel": "ツール出力をコピー",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.label": "差分",
"toolCall.diff.label.withPath": "差分 · {path}",
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
"toolCall.diff.viewMode.split": "分割",
"toolCall.diff.viewMode.unified": "ユニファイド",
@ -53,7 +59,7 @@ export const toolCallMessages = {
"toolCall.renderer.read.detail.offset": "オフセット: {offset}",
"toolCall.renderer.read.detail.limit": "上限: {limit}",
"toolCall.renderer.todo.empty": "まだ項目がありません。",
"toolCall.renderer.todo.empty": "計画済みの項目はありません",
"toolCall.renderer.todo.status.pending": "保留",
"toolCall.renderer.todo.status.inProgress": "進行中",
"toolCall.renderer.todo.status.completed": "完了",
@ -71,13 +77,9 @@ export const toolCallMessages = {
"toolCall.permission.actions.allowOnce": "一度だけ許可",
"toolCall.permission.actions.alwaysAllow": "常に許可",
"toolCall.permission.actions.deny": "拒否",
"toolCall.permission.actions.confirmDeny": "拒否を確定",
"toolCall.permission.actions.cancel": "キャンセル",
"toolCall.permission.shortcuts.allowOnce": "一度だけ許可",
"toolCall.permission.shortcuts.alwaysAllow": "常に許可",
"toolCall.permission.rejectReason.label": "この許可を拒否する理由は?",
"toolCall.permission.rejectReason.placeholder": "CodeNomad に別の対応を伝えてください...",
"toolCall.permission.rejectReason.hint": "エージェントはこのフィードバックを見て、次の操作を調整できます。",
"toolCall.permission.rejectReason.placeholder": "拒否時の任意フィードバック(例: より安全なコマンドを使う)...",
"toolCall.permission.errors.unableToUpdate": "許可を更新できません",
"permissionApproval.title": "リクエスト",
@ -94,11 +96,7 @@ export const toolCallMessages = {
"permissionApproval.actions.allowOnce": "一度だけ許可",
"permissionApproval.actions.alwaysAllow": "常に許可",
"permissionApproval.actions.deny": "拒否",
"permissionApproval.actions.confirmDeny": "拒否を確定",
"permissionApproval.actions.cancel": "キャンセル",
"permissionApproval.rejectReason.label": "この許可を拒否する理由は?",
"permissionApproval.rejectReason.placeholder": "CodeNomad に別の対応を伝えてください...",
"permissionApproval.rejectReason.hint": "エージェントはこのフィードバックを見て、次の操作を調整できます。",
"permissionApproval.rejectReason.placeholder": "拒否時の任意フィードバック(例: より安全なコマンドを使う)...",
"permissionApproval.fallbackHint": "詳しい情報を見るにはセッションを読み込んでください。",
"permissionApproval.errors.unableToUpdatePermission": "許可を更新できません",

View file

@ -12,6 +12,7 @@ export const appMessages = {
"app.stopInstance.title": "उदाहरण रोक्नुहोस्",
"app.stopInstance.confirmLabel": "रोक्नुहोस्",
"app.stopInstance.cancelLabel": "चालु राख्नुहोस्",
"app.stopInstance.toast.error": "कार्यस्थान रोक्न असफल भयो।",
"emptyState.logoAlt": "CodeNomad लोगो",
"emptyState.brandTitle": "CodeNomad",

View file

@ -75,15 +75,15 @@ export const folderSelectionMessages = {
"folderSelection.servers.connectSubtitle": "रिमोट CodeNomad सर्भर बचत गर्नुहोस् र यसलाई नयाँ विन्डोमा खोल्नुहोस्",
"folderSelection.servers.connectButton": "सर्भरमा जडान गर्नुहोस्",
"folderSelection.servers.remove": "बचत गरिएको सर्भर हटाउनुहोस्",
"folderSelection.servers.skipTls": "Self-signed TLS",
"folderSelection.servers.skipTls": "स्व-हस्ताक्षरित TLS",
"folderSelection.servers.errorTitle": "रिमोट जडान असफल भयो",
"folderSelection.servers.dialog.title": "सर्भरमा जडान गर्नुहोस्",
"folderSelection.servers.dialog.description": "रिमोट CodeNomad सर्भर थप्नुहोस् र वैकल्पिक रूपमा यसलाई तुरुन्तै खोल्नुहोस्।",
"folderSelection.servers.dialog.name": "सर्भरको नाम",
"folderSelection.servers.dialog.namePlaceholder": "Production Server",
"folderSelection.servers.dialog.namePlaceholder": "उत्पादन सर्भर",
"folderSelection.servers.dialog.url": "सर्भर URL",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "Self-signed प्रमाणपत्रहरूको लागि TLS प्रमाणीकरण छोड्नुहोस्।",
"folderSelection.servers.dialog.skipTls": "स्व-हस्ताक्षरित प्रमाणपत्रहरूको लागि TLS प्रमाणीकरण छोड्नुहोस्।",
"folderSelection.servers.dialog.cancel": "रद्द गर्नुहोस्",
"folderSelection.servers.dialog.save": "बचत गर्नुहोस्",
"folderSelection.servers.dialog.connect": "जडान गर्नुहोस्",
@ -91,10 +91,10 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.errorRequired": "सर्भरको नाम र URL आवश्यक छ।",
"folderSelection.servers.dialog.errorConnect": "रिमोट सर्भरमा जडान गर्न सकिएन।",
"folderSelection.servers.certificateInstall.title": "स्थानीय प्रमाणपत्र स्थापना गर्नुहोस्",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad लाई self-signed HTTPS रिमोट विन्डोहरू खोल्न स्थानीय प्रमाणपत्र स्थापना गर्न आवश्यक छ। यो प्रमाणपत्र तपाईंको मेसिनमा स्थानीय डेस्कटप प्रोक्सी ट्राफिकको लागि मात्र प्रयोग गरिन्छ। तपाईंको अपरेटिङ सिस्टमले यसपछि दोस्रो प्रमाणपत्र प्रम्प्ट देखाउन सक्छ।",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad लाई स्व-हस्ताक्षरित HTTPS रिमोट विन्डोहरू खोल्न स्थानीय प्रमाणपत्र स्थापना गर्न आवश्यक छ। यो प्रमाणपत्र तपाईंको मेसिनमा स्थानीय डेस्कटप प्रोक्सी ट्राफिकको लागि मात्र प्रयोग गरिन्छ। तपाईंको अपरेटिङ सिस्टमले यसपछि दोस्रो प्रमाणपत्र प्रम्प्ट देखाउन सक्छ।",
"folderSelection.servers.certificateInstall.confirmLabel": "जारी राख्नुहोस्",
"folderSelection.servers.certificateInstall.cancelLabel": "रद्द गर्नुहोस्",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad लाई self-signed HTTPS रिमोट विन्डोहरू खोल्न सक्नु अघि स्थानीय प्रमाणपत्र विश्वास गरिनु पर्छ।",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad लाई स्व-हस्ताक्षरित HTTPS रिमोट विन्डोहरू खोल्न सक्नु अघि स्थानीय प्रमाणपत्र विश्वास गरिनु पर्छ।",
"folderSelection.sidecars.button": "SideCar खोल्नुहोस्",
"projectRenameDialog.title": "कार्यस्थान पुन: नामकरण गर्नुहोस्",

View file

@ -20,7 +20,7 @@ export const instanceMessages = {
"instanceTab.status.permission": "अनुमतिको प्रतीक्षा गर्दै",
"instanceTab.status.compacting": "संक्षिप्त गर्दै",
"instanceTab.status.working": "काम गर्दै",
"instanceTab.status.idle": "Idle",
"instanceTab.status.idle": "निष्क्रिय",
"instanceTab.status.ariaLabel": "उदाहरण स्थिति: {status}",
"instanceTab.actions.close.ariaLabel": "उदाहरण बन्द गर्नुहोस्",
@ -130,10 +130,10 @@ export const instanceMessages = {
"instanceShell.gitChanges.actions.stage": "फाइल स्टेज गर्नुहोस्",
"instanceShell.gitChanges.actions.unstage": "फाइल अनस्टेज गर्नुहोस्",
"instanceShell.gitChanges.commit.placeholder": "Commit सन्देश प्रविष्ट गर्नुहोस्",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "Commit गर्दै...",
"instanceShell.gitChanges.commit.success": "Commit सफलतापूर्वक सिर्जना गरियो",
"instanceShell.gitChanges.commit.error": "Commit सिर्जना गर्न असफल भयो",
"instanceShell.gitChanges.commit.submit": "कमिट गर्नुहोस्",
"instanceShell.gitChanges.commit.submitting": "कमिट गर्दै...",
"instanceShell.gitChanges.commit.success": "कमिट सफलतापूर्वक सिर्जना गरियो",
"instanceShell.gitChanges.commit.error": "कमिट सिर्जना गर्न असफल भयो",
"instanceShell.filesShell.fileListTitle": "फाइल सूची",
"instanceShell.filesShell.mobileSelectorLabel": "फाइल चयन गर्नुहोस्",

View file

@ -20,6 +20,7 @@ export const messagingMessages = {
"messageSection.loadError.reload": "सन्देशहरू फेरि लोड गर्नुहोस्",
"messageSection.scroll.toFirstAriaLabel": "पहिलो सन्देशमा जानुहोस्",
"messageSection.scroll.toLatestAriaLabel": "भर्खरको सन्देशमा जानुहोस्",
"messageSection.scroll.showControlsAriaLabel": "स्क्रोल नियन्त्रणहरू देखाउनुहोस्",
"messageSection.scroll.enableHoldAriaLabel": "लामो प्रतिक्रियाहरूको लागि होल्ड सक्षम गर्नुहोस्",
"messageSection.scroll.disableHoldAriaLabel": "लामो प्रतिक्रियाहरूको लागि होल्ड अक्षम गर्नुहोस्",
"messageSection.quote.addAsQuote": "उद्धरणको रूपमा थप्नुहोस्",
@ -42,7 +43,7 @@ export const messagingMessages = {
"messageTimeline.ariaLabel": "सन्देश टाइमलाइन",
"messageTimeline.segment.user.label": "तपाईं",
"messageTimeline.segment.assistant.label": "सहायक",
"messageTimeline.segment.compaction.label": "Compaction",
"messageTimeline.segment.compaction.label": "संक्षिप्तीकरण",
"messageTimeline.tool.fallbackLabel": "उपकरण कल",
"messageTimeline.tooltip.userFallback": "प्रयोगकर्ता सन्देश",
"messageTimeline.tooltip.assistantFallback": "सहायक प्रतिक्रिया",
@ -67,8 +68,8 @@ export const messagingMessages = {
"messageBlock.usage.input": "इनपुट",
"messageBlock.usage.output": "आउटपुट",
"messageBlock.usage.reasoning": "तर्क (Reasoning)",
"messageBlock.usage.cacheRead": "Cache Read",
"messageBlock.usage.cacheWrite": "Cache Write",
"messageBlock.usage.cacheRead": "क्यास पढाइ",
"messageBlock.usage.cacheWrite": "क्यास लेखाइ",
"messageBlock.usage.cost": "लागत (Cost)",
"messageBlock.step.agentLabel": "एजेन्ट: {agent}",
"messageBlock.step.modelLabel": "मोडेल: {model}",
@ -78,6 +79,15 @@ export const messagingMessages = {
"messageBlock.reasoning.indicator.hide": "लुकाउनुहोस्",
"messageBlock.reasoning.indicator.view": "हेर्नुहोस्",
"messageBlock.reasoning.detailsAriaLabel": "तर्क विवरणहरू",
"messageBlock.reasoning.thoughtsFallback": "विचारहरू",
"messageBlock.reasoning.thoughtFor.seconds.one": "{count} सेकेन्ड सोच्यो",
"messageBlock.reasoning.thoughtFor.seconds.other": "{count} सेकेन्ड सोच्यो",
"messageBlock.reasoning.thoughtFor.minutes.one": "{count} मिनेट सोच्यो",
"messageBlock.reasoning.thoughtFor.minutes.other": "{count} मिनेट सोच्यो",
"messageBlock.reasoning.thoughtFor.hours.one": "{count} घण्टा सोच्यो",
"messageBlock.reasoning.thoughtFor.hours.other": "{count} घण्टा सोच्यो",
"messageBlock.reasoning.copyTitle": "विचारहरू प्रतिलिपि गर्नुहोस्",
"messageBlock.reasoning.copyAriaLabel": "विचारहरू प्रतिलिपि गर्नुहोस्",
"codeBlockInline.actions.copy": "प्रतिलिपि गर्नुहोस्",
"codeBlockInline.actions.copied": "प्रतिलिपि गरियो!",
@ -86,8 +96,8 @@ export const messagingMessages = {
"messageItem.speaker.assistant": "सहायक",
"messageItem.actions.revert": "फिर्ता",
"messageItem.actions.revertTitle": "यहाँ सम्मका परिवर्तनहरू फिर्ता लिनुहोस् (सन्देशहरू मेटाउँछ)",
"messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "यो सन्देशबाट Fork गर्नुहोस्",
"messageItem.actions.fork": "फोर्क",
"messageItem.actions.forkTitle": "यो सन्देशबाट फोर्क गर्नुहोस्",
"messageItem.actions.copy": "प्रतिलिपि गर्नुहोस्",
"messageItem.actions.copyTitle": "सन्देश प्रतिलिपि गर्नुहोस्",
"messageItem.actions.copied": "प्रतिलिपि गरियो!",
@ -106,6 +116,14 @@ export const messagingMessages = {
"messageItem.actions.deleteMessageFailedMessage": "सन्देश मेटाउन असफल भयो",
"messageItem.selection.checkboxAriaLabel": "मेटाउनको लागि सन्देश चयन गर्नुहोस्",
"messageItem.selection.selectForDeletion": "मेटाउनको लागि सन्देश चयन गर्नुहोस्",
"messageItem.selection.deselectForDeletion": "मेटाउनको लागि सन्देश चयन हटाउनुहोस्",
"messageItem.duration.workedFor.seconds.one": "{count} सेकेन्ड काम गर्‍यो",
"messageItem.duration.workedFor.seconds.other": "{count} सेकेन्ड काम गर्‍यो",
"messageItem.duration.workedFor.minutes.one": "{count} मिनेट काम गर्‍यो",
"messageItem.duration.workedFor.minutes.other": "{count} मिनेट काम गर्‍यो",
"messageItem.duration.workedFor.hours.one": "{count} घण्टा काम गर्‍यो",
"messageItem.duration.workedFor.hours.other": "{count} घण्टा काम गर्‍यो",
"messageSection.bulkDelete.toolbarAriaLabel": "चयन गरिएका वस्तुहरू ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "चयन गरिएका वस्तुहरू मेटाउनुहोस्",
@ -120,7 +138,7 @@ export const messagingMessages = {
"messageSection.bulkDelete.cancelTitle": "चयन रद्द गर्नुहोस्",
"messageSection.bulkDelete.failedTitle": "मेटाउन असफल",
"messageSection.bulkDelete.failedMessage": "चयन गरिएका वस्तुहरू मेटाउन असफल भयो",
"messageItem.status.queued": "Queued",
"messageItem.status.queued": "लामबद्ध",
"messageItem.status.generating": "सिर्जना गर्दै...",
"messageItem.status.sending": "पठाउँदै...",
"messageItem.status.failedToSend": "सन्देश पठाउन असफल भयो",
@ -129,6 +147,10 @@ export const messagingMessages = {
"messagePart.actions.deleteTitle": "यो वस्तु मेटाउनुहोस्",
"messagePart.actions.deleteFailedTitle": "मेटाउन असफल",
"messagePart.actions.deleteFailedMessage": "वस्तु मेटाउन असफल भयो",
"messagePart.pastedText.summary": "टाँसिएको पाठ",
"messagePart.pastedText.lines.one": "{count} पङ्क्ति",
"messagePart.pastedText.lines.other": "{count} पङ्क्तिहरू",
"messagePart.pastedText.copyAriaLabel": "टाँसिएको पाठ ब्लक प्रतिलिपि गर्नुहोस्",
"messageItem.attachment.defaultName": "संलग्नक (attachment)",
"messageItem.attachment.downloadAriaLabel": "{name} डाउनलोड गर्नुहोस्",
"messageItem.agentMeta.agentLabel": "एजेन्ट: {agent}",

View file

@ -44,7 +44,7 @@ export const remoteAccessMessages = {
"remoteAccess.addresses.actions.showOther": "अन्य {count} ठेगानाहरू देखाउनुहोस्",
"remoteAccess.addresses.actions.hideOther": "अन्य ठेगानाहरू लुकाउनुहोस्",
"remoteAccess.address.scope.network": "नेटवर्क",
"remoteAccess.address.scope.loopback": "Loopback",
"remoteAccess.address.scope.loopback": "लूपब्याक",
"remoteAccess.address.scope.internal": "आन्तरिक",
"remoteAccess.address.open": "खोल्नुहोस्",
"remoteAccess.address.showQr": "QR देखाउनुहोस्",

View file

@ -14,7 +14,7 @@ export const sessionMessages = {
"sessionList.session.untitled": "शीर्षक नभएको सत्र",
"sessionList.status.working": "काम गर्दै",
"sessionList.status.compacting": "संक्षिप्त गर्दै",
"sessionList.status.idle": "Idle",
"sessionList.status.idle": "निष्क्रिय",
"sessionList.status.retrying": "पुन: प्रयास गर्दै",
"sessionList.status.retryingIn": "{seconds} सेकेन्डमा पुन: प्रयास गर्दै",
"sessionList.status.retryTooltip": "{message} (प्रयास {attempt})",
@ -77,8 +77,8 @@ export const sessionMessages = {
"sessionView.alerts.revertFailed.title": "फिर्ता गर्न असफल",
"sessionView.alerts.deleteUpToFailed.message": "सन्देशहरू मेटाउन असफल भयो",
"sessionView.alerts.deleteUpToFailed.title": "मेटाउन असफल",
"sessionView.alerts.forkFailed.message": "सत्र Fork गर्न असफल भयो",
"sessionView.alerts.forkFailed.title": "Fork असफल भयो",
"sessionView.alerts.forkFailed.message": "सत्र फोर्क गर्न असफल भयो",
"sessionView.alerts.forkFailed.title": "फोर्क असफल भयो",
"sessionView.attachments.expandPastedTextAriaLabel": "टाँसिएको पाठ विस्तार गर्नुहोस्",
"sessionView.attachments.insertPastedTextTitle": "टाँसिएको पाठ घुसाउनुहोस्",
"sessionView.attachments.removeAriaLabel": "संलग्नक हटाउनुहोस्",
@ -115,7 +115,7 @@ export const sessionMessages = {
"sessionState.cleanup.deepConfirm.message": "यो सफाई ढिलो हुन सक्छ र तपाईंले नचाहेका सत्रहरू मेटाउन सक्छ। के तपाईं निश्चित हुनुहुन्छ?",
"sessionState.cleanup.deepConfirm.title": "सत्रहरूको गहिरो सफाई (Deep Clean)",
"sessionState.cleanup.deepConfirm.detail": "गहिरो सफाईले कुनै सन्देश नभएका सबै सत्रहरू, समाप्त भएका उप-एजेन्ट सत्रहरू र प्रयोग नगरिएका Fork हरू मेटाउनेछ।",
"sessionState.cleanup.deepConfirm.detail": "गहिरो सफाईले कुनै सन्देश नभएका सबै सत्रहरू, समाप्त भएका उप-एजेन्ट सत्रहरू र प्रयोग नगरिएका फोर्कहरू मेटाउनेछ।",
"sessionState.cleanup.deepConfirm.confirmLabel": "जारी राख्नुहोस्",
"sessionState.cleanup.deepConfirm.cancelLabel": "रद्द गर्नुहोस्",
"sessionState.cleanup.toast.one": "{count} खाली सत्र सफा गरियो",

Some files were not shown because too many files have changed in this diff Show more