Compare commits

..

5 Commits

Author SHA1 Message Date
Shantur Rathore
bf07904789 feat(speech): make prompt input push to talk 2026-03-24 22:42:27 +00:00
Shantur Rathore
4e576829b7 Revert "feat(speech): add realtime prompt dictation support"
This reverts commit f9b5e2b529.
2026-03-24 20:52:04 +00:00
Shantur Rathore
f9b5e2b529 feat(speech): add realtime prompt dictation support
Add server-backed realtime transcription for prompt voice input and expose speech settings to choose realtime mode and models.
2026-03-19 11:32:45 +00:00
Shantur Rathore
cc2f6976f6 fix(speech): preserve edits while saving settings 2026-03-13 08:34:34 +00:00
Shantur Rathore
0ed19aeefb feat(speech): add prompt voice input groundwork 2026-03-12 22:04:57 +00:00
94 changed files with 4522 additions and 2648 deletions

View File

@@ -28,21 +28,6 @@ on:
required: false required: false
default: true default: true
type: boolean type: boolean
upload_actions_artifacts:
description: "Upload built artifacts to GitHub Actions run artifacts"
required: false
default: false
type: boolean
actions_artifacts_retention_days:
description: "Retention (days) for GitHub Actions artifacts"
required: false
default: 7
type: number
actions_artifacts_name_prefix:
description: "Optional prefix for Actions artifact names"
required: false
default: ""
type: string
set_versions: set_versions:
description: "Run npm version to set workspace versions" description: "Run npm version to set workspace versions"
required: false required: false
@@ -218,15 +203,6 @@ jobs:
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
done done
- name: Upload Actions artifacts (Electron macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-windows: build-windows:
runs-on: windows-2025 runs-on: windows-2025
env: env:
@@ -268,15 +244,6 @@ jobs:
gh release upload $env:TAG $_.FullName --clobber gh release upload $env:TAG $_.FullName --clobber
} }
- name: Upload Actions artifacts (Electron Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-linux: build-linux:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
env: env:
@@ -319,15 +286,6 @@ jobs:
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
done done
- name: Upload Actions artifacts (Electron Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-tauri-macos: build-tauri-macos:
runs-on: macos-15-intel runs-on: macos-15-intel
env: env:
@@ -381,7 +339,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS) - name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload || inputs.upload_actions_artifacts }} if: ${{ inputs.upload }}
run: | run: |
set -euo pipefail set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle" BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -392,15 +350,6 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip" ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
fi fi
- name: Upload Actions artifacts (Tauri macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS) - name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -465,7 +414,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64) - name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload || inputs.upload_actions_artifacts }} if: ${{ inputs.upload }}
run: | run: |
set -euo pipefail set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle" BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -476,15 +425,6 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip" ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
fi fi
- name: Upload Actions artifacts (Tauri macOS arm64)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS arm64) - name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -552,7 +492,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows) - name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload || inputs.upload_actions_artifacts }} if: ${{ inputs.upload }}
shell: pwsh shell: pwsh
run: | run: |
$bundleRoot = "packages/tauri-app/target/release/bundle" $bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -565,15 +505,6 @@ jobs:
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
} }
- name: Upload Actions artifacts (Tauri Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Windows) - name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh shell: pwsh
@@ -651,7 +582,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux) - name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload || inputs.upload_actions_artifacts }} if: ${{ inputs.upload }}
run: | run: |
set -euo pipefail set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target" SEARCH_ROOT="packages/tauri-app/target"
@@ -677,15 +608,6 @@ jobs:
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb" cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm" cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Actions artifacts (Tauri Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
path: packages/tauri-app/release-tauri/*
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Linux) - name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -844,12 +766,3 @@ jobs:
echo "Uploading $file" echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
done done
- name: Upload Actions artifacts (Electron Linux RPM)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
path: packages/electron-app/release/*.rpm
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error

View File

@@ -1,121 +0,0 @@
name: Comment PR Artifacts
on:
pull_request_target:
types:
- opened
- synchronize
- reopened
- ready_for_review
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RETENTION_DAYS: 7
steps:
- name: Check PR authorization
id: auth
shell: bash
run: |
set -euo pipefail
if [ "$BASE_REF" = "dev" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
fi
- name: Wait for PR build and comment
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = Number(process.env.PR_NUMBER);
const headSha = process.env.HEAD_SHA;
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
const marker = '<!-- codenomad-pr-artifacts -->';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let matchedRun = null;
for (let attempt = 1; attempt <= 30; attempt += 1) {
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: 'pr-build.yml',
event: 'pull_request',
per_page: 100,
});
const matchingRuns = runs
.filter((run) => run.head_sha === headSha)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
matchedRun = matchingRuns[0] || null;
if (matchedRun && matchedRun.status === 'completed') {
break;
}
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
await sleep(10000);
}
if (!matchedRun) {
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
return;
}
if (matchedRun.status !== 'completed') {
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
return;
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
);
const active = artifacts.filter((artifact) => !artifact.expired);
const runUrl = matchedRun.html_url;
const artifactsBlock = active.length
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
: 'Artifacts: (none found on this run)';
const body = [
marker,
'PR builds are available as GitHub Actions artifacts:',
'',
runUrl,
'',
`Artifacts expire in ${retentionDays} days.`,
artifactsBlock,
].join('\n');
const created = await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
core.info(`Created artifacts comment: ${created.data.html_url}`);

View File

@@ -1,57 +0,0 @@
name: PR Build Validation
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
permissions:
contents: read
actions: write
concurrency:
group: pr-build-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
authorize:
runs-on: ubuntu-latest
outputs:
allowed: ${{ steps.auth.outputs.allowed }}
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check PR authorization
id: auth
shell: bash
run: |
set -euo pipefail
if [ "$BASE_REF" = "dev" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
fi
build:
needs: authorize
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
uses: ./.github/workflows/build-and-upload.yml
with:
ref: ${{ github.event.pull_request.head.sha }}
upload: false
upload_actions_artifacts: true
actions_artifacts_retention_days: 7
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
set_versions: false

View File

@@ -1,54 +0,0 @@
name: Restrict Non-Dev PRs
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
permissions:
contents: read
pull-requests: write
jobs:
restrict-non-dev-prs:
if: ${{ github.event.pull_request.base.ref != 'dev' }}
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check allowed actor
id: auth
shell: bash
run: |
set -euo pipefail
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
echo "authorized=false" >> "$GITHUB_OUTPUT"
fi
- name: Comment on unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
- name: Close unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr close "$PR_NUMBER"
- name: Fail unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
run: |
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
exit 1

92
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.3", "version": "0.12.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.3", "version": "0.12.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -3253,9 +3253,9 @@
} }
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.10.1", "version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", "integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -3322,15 +3322,6 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-notification": { "node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
@@ -8240,6 +8231,27 @@
"regex-recursion": "^6.0.2" "regex-recursion": "^6.0.2"
} }
}, },
"node_modules/openai": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/own-keys": { "node_modules/own-keys": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -10244,6 +10256,14 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/tauri-plugin-keepawake-api": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
}
},
"node_modules/temp-dir": { "node_modules/temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -10984,36 +11004,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/virtua": {
"version": "0.48.8",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=5.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"dev": true, "dev": true,
@@ -12019,6 +12009,7 @@
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -12033,7 +12024,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.3", "version": "0.12.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12070,7 +12061,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.3", "version": "0.12.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12080,6 +12071,7 @@
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2", "yaml": "^2.4.2",
@@ -12111,7 +12103,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.12.3", "version": "0.12.2",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12119,7 +12111,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.12.3", "version": "0.12.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
@@ -12129,8 +12121,6 @@
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
"@suid/system": "^0.14.0", "@suid/system": "^0.14.0",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
"ansi-sequence-parser": "^1.1.3", "ansi-sequence-parser": "^1.1.3",
@@ -12143,7 +12133,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"virtua": "^0.48.8", "tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.3", "version": "0.12.2",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",
@@ -31,4 +31,4 @@
"devDependencies": { "devDependencies": {
"baseline-browser-mapping": "^2.9.11" "baseline-browser-mapping": "^2.9.11"
} }
} }

View File

@@ -1,4 +1,4 @@
{ {
"minServerVersion": "0.12.3", "minServerVersion": "0.11.4",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
} }

View File

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

View File

@@ -4,6 +4,6 @@
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.2.24" "@opencode-ai/plugin": "1.2.14"
} }
} }

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.3", "version": "0.12.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.3", "version": "0.12.2",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.3", "version": "0.12.2",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -32,6 +32,7 @@
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2", "yaml": "^2.4.2",
@@ -46,4 +47,4 @@
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -207,6 +207,36 @@ export interface BinaryValidationResult {
error?: string error?: string
} }
export interface SpeechSegment {
startMs: number
endMs: number
text: string
}
export interface SpeechCapabilitiesResponse {
available: boolean
configured: boolean
provider: string
supportsStt: boolean
supportsTts: boolean
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
}
export interface SpeechTranscriptionResponse {
text: string
language?: string
durationMs?: number
segments?: SpeechSegment[]
}
export interface SpeechSynthesisResponse {
audioBase64: string
mimeType: string
}
export type WorkspaceEventType = export type WorkspaceEventType =
| "workspace.created" | "workspace.created"
| "workspace.started" | "workspace.started"

View File

@@ -23,6 +23,7 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } fro
import { resolveHttpsOptions } from "./server/tls" import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses" import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -304,6 +305,7 @@ async function main() {
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir) const instanceStore = new InstanceStore(configLocation.instancesDir)
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
const instanceEventBridge = new InstanceEventBridge({ const instanceEventBridge = new InstanceEventBridge({
workspaceManager, workspaceManager,
eventBus, eventBus,
@@ -388,6 +390,7 @@ async function main() {
eventBus, eventBus,
serverMeta, serverMeta,
instanceStore, instanceStore,
speechService,
authManager, authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl, uiDevServerUrl: uiResolution.uiDevServerUrl,
@@ -408,6 +411,7 @@ async function main() {
eventBus, eventBus,
serverMeta, serverMeta,
instanceStore, instanceStore,
speechService,
authManager, authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined, uiDevServerUrl: undefined,

View File

@@ -21,12 +21,14 @@ import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin" import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees" import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { ServerMeta } from "../api-types" import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store" import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager" import { BackgroundProcessManager } from "../background-processes/manager"
import type { AuthManager } from "../auth/manager" import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth" import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
import type { SpeechService } from "../speech/service"
interface HttpServerDeps { interface HttpServerDeps {
bindHost: string bindHost: string
@@ -41,6 +43,7 @@ interface HttpServerDeps {
eventBus: EventBus eventBus: EventBus
serverMeta: ServerMeta serverMeta: ServerMeta
instanceStore: InstanceStore instanceStore: InstanceStore
speechService: SpeechService
authManager: AuthManager authManager: AuthManager
uiStaticDir: string uiStaticDir: string
uiDevServerUrl?: string uiDevServerUrl?: string
@@ -252,6 +255,7 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
}) })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -0,0 +1,46 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SpeechService } from "../../speech/service"
interface RouteDeps {
speechService: SpeechService
}
const TranscribeBodySchema = z.object({
audioBase64: z.string().min(1, "Audio payload is required"),
mimeType: z.string().min(1, "Audio MIME type is required"),
filename: z.string().optional(),
language: z.string().optional(),
prompt: z.string().optional(),
})
const SynthesizeBodySchema = z.object({
text: z.string().trim().min(1, "Text is required"),
format: z.enum(["mp3", "wav", "opus"]).optional(),
})
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
app.post("/api/speech/transcribe", async (request, reply) => {
try {
const body = TranscribeBodySchema.parse(request.body ?? {})
return await deps.speechService.transcribe(body)
} catch (error) {
request.log.error({ err: error }, "Failed to transcribe audio")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to transcribe audio" }
}
})
app.post("/api/speech/synthesize", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
return await deps.speechService.synthesize(body)
} catch (error) {
request.log.error({ err: error }, "Failed to synthesize audio")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to synthesize audio" }
}
})
}

View File

@@ -0,0 +1,148 @@
import OpenAI from "openai"
import { toFile } from "openai/uploads"
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
import type { Logger } from "../../logger"
import type { NormalizedSpeechSettings, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
interface OpenAICompatibleSpeechProviderOptions {
settings: NormalizedSpeechSettings
logger: Logger
}
export class OpenAICompatibleSpeechProvider {
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
getCapabilities() {
const { settings } = this.options
return {
available: true,
configured: Boolean(settings.apiKey),
provider: settings.provider,
supportsStt: true,
supportsTts: true,
baseUrl: settings.baseUrl,
sttModel: settings.sttModel,
ttsModel: settings.ttsModel,
ttsVoice: settings.ttsVoice,
}
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
const client = this.createClient()
const startedAt = Date.now()
const extension = extensionForMime(input.mimeType)
const buffer = Buffer.from(input.audioBase64, "base64")
const filename = input.filename?.trim() || `prompt-input.${extension}`
this.options.logger.info(
{
mimeType: input.mimeType,
bytes: buffer.byteLength,
language: input.language,
model: this.options.settings.sttModel,
},
"speech.transcribe",
)
const response = await this.requestTranscription(client, buffer, filename, input)
return {
text: typeof response?.text === "string" ? response.text : "",
language: typeof response?.language === "string" ? response.language : input.language,
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
segments: Array.isArray(response?.segments)
? response.segments
.filter((segment: any) => typeof segment?.text === "string")
.map((segment: any) => ({
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
text: String(segment.text),
}))
: undefined,
}
}
private async requestTranscription(
client: OpenAI,
buffer: Buffer,
filename: string,
input: TranscribeAudioInput,
): Promise<any> {
const baseRequest = {
model: this.options.settings.sttModel,
...(input.language ? { language: input.language } : {}),
...(input.prompt ? { prompt: input.prompt } : {}),
}
try {
const file = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file,
response_format: "verbose_json" as any,
} as any)) as any
} catch (error) {
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file: retryFile,
} as any)) as any
}
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
const client = this.createClient()
const format = input.format ?? "mp3"
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize",
)
const response = await client.audio.speech.create({
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice as any,
input: input.text,
response_format: format as any,
})
const audioBuffer = Buffer.from(await response.arrayBuffer())
return {
audioBase64: audioBuffer.toString("base64"),
mimeType: mimeTypeForFormat(format),
}
}
private createClient(): OpenAI {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
return new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseUrl,
})
}
}
function extensionForMime(mimeType: string): string {
const normalized = mimeType.toLowerCase()
if (normalized.includes("webm")) return "webm"
if (normalized.includes("ogg")) return "ogg"
if (normalized.includes("wav")) return "wav"
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
return "webm"
}
function mimeTypeForFormat(format: "mp3" | "wav" | "opus"): string {
if (format === "wav") return "audio/wav"
if (format === "opus") return "audio/opus"
return "audio/mpeg"
}

View File

@@ -0,0 +1,91 @@
import { z } from "zod"
import type { Logger } from "../logger"
import type { SettingsService } from "../settings/service"
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
const ServerSpeechSettingsSchema = z.object({
speech: z
.object({
provider: z.string().optional(),
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
sttModel: z.string().optional(),
ttsModel: z.string().optional(),
ttsVoice: z.string().optional(),
})
.optional(),
})
export interface TranscribeAudioInput {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}
export interface SynthesizeSpeechInput {
text: string
format?: "mp3" | "wav" | "opus"
}
export interface SpeechProvider {
getCapabilities(): SpeechCapabilitiesResponse
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
}
export interface NormalizedSpeechSettings {
provider: string
apiKey?: string
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
}
const DEFAULT_PROVIDER = "openai-compatible"
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
const DEFAULT_TTS_VOICE = "alloy"
export class SpeechService {
constructor(
private readonly settings: SettingsService,
private readonly logger: Logger,
) {}
getCapabilities(): SpeechCapabilitiesResponse {
return this.createProvider().getCapabilities()
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
return this.createProvider().transcribe(input)
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
return this.createProvider().synthesize(input)
}
private createProvider(): SpeechProvider {
const settings = this.resolveSettings()
return new OpenAICompatibleSpeechProvider({
settings,
logger: this.logger.child({ provider: settings.provider }),
})
}
private resolveSettings(): NormalizedSpeechSettings {
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
const speech = parsed.speech ?? {}
return {
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -20,7 +20,6 @@ const serverDevInstallCommand =
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" "npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand = const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" "npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
const envWithRootBin = { const envWithRootBin = {
...process.env, ...process.env,
@@ -92,15 +91,6 @@ function ensureUiBuild() {
} }
} }
function syncServerUiBundle() {
console.log("[prebuild] syncing server public UI bundle...")
execSync(serverPrepareUiCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDevDependencies() { function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) { if (fs.existsSync(braceExpansionPath)) {
return return
@@ -256,7 +246,6 @@ function copyUiLoadingAssets() {
ensureServerDependencies() ensureServerDependencies()
ensureServerBuild() ensureServerBuild()
ensureUiBuild() ensureUiBuild()
syncServerUiBundle()
copyServerArtifacts() copyServerArtifacts()
stripNodeModuleBins() stripNodeModuleBins()
copyUiLoadingAssets() copyUiLoadingAssets()

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.12.3" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
@@ -19,12 +19,9 @@ thiserror = "1"
anyhow = "1" anyhow = "1"
which = "4" which = "4"
libc = "0.2" libc = "0.2"
keepawake = "0.6"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
dirs = "5" dirs = "5"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
url = "2" url = "2"
tauri-plugin-keepawake = "0.1.1"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }

View File

@@ -11,7 +11,6 @@
"core:menu:default", "core:menu:default",
"dialog:allow-open", "dialog:allow-open",
"opener:allow-default-urls", "opener:allow-default-urls",
"opener:allow-open-url",
"notification:allow-is-permission-granted", "notification:allow-is-permission-granted",
"notification:allow-request-permission", "notification:allow-request-permission",
"notification:allow-notify", "notification:allow-notify",

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}} {"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}

View File

@@ -2378,6 +2378,36 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string", "type": "string",

View File

@@ -2378,6 +2378,36 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string", "type": "string",

View File

@@ -9,8 +9,6 @@ use std::ffi::OsStr;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream; use std::net::TcpStream;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -19,24 +17,10 @@ use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
fn log_line(message: &str) { fn log_line(message: &str) {
println!("[tauri-cli] {message}"); println!("[tauri-cli] {message}");
} }
#[cfg(windows)]
fn configure_spawn(command: &mut Command) {
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn configure_spawn(_command: &mut Command) {}
fn workspace_root() -> Option<PathBuf> { fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| { std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 { for _ in 0..3 {
@@ -52,46 +36,6 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30; const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(unix)]
fn configure_posix_process_group(command: &mut Command) {
// Ensure the CLI runs in its own process group so we can terminate wrapper
// processes (login shell/tsx) without leaving the server orphaned.
unsafe {
command.pre_exec(|| {
if libc::setpgid(0, 0) != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(windows)]
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
if force {
args.push("/F".to_string());
}
let mut command = Command::new("taskkill");
command.args(&args);
configure_spawn(&mut command);
match command.output() {
Ok(output) => {
if output.status.success() {
return true;
}
// If the PID is already gone, treat it as success.
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
let combined = format!("{stdout}\n{stderr}");
combined.contains("not found") || combined.contains("no running instance")
}
Err(_) => false,
}
}
fn navigate_main(app: &AppHandle, url: &str) { fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") { if let Some(win) = app.webview_windows().get("main") {
let mut display = url.to_string(); let mut display = url.to_string();
@@ -404,19 +348,11 @@ impl CliProcessManager {
log_line(&format!("stopping CLI pid={}", child.id())); log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
let pid = child.id() as i32; libc::kill(child.id() as i32, libc::SIGTERM);
// Prefer signaling the process group to avoid orphaning children
// when the CLI was launched via a wrapper shell.
let group_res = libc::kill(-pid, libc::SIGTERM);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGTERM);
}
} }
#[cfg(windows)] #[cfg(windows)]
{ {
if !kill_process_tree_windows(child.id(), false) { let _ = child.kill();
let _ = child.kill();
}
} }
let start = Instant::now(); let start = Instant::now();
@@ -432,17 +368,11 @@ impl CliProcessManager {
)); ));
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
let pid = child.id() as i32; libc::kill(child.id() as i32, libc::SIGKILL);
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
} }
#[cfg(windows)] #[cfg(windows)]
{ {
if !kill_process_tree_windows(child.id(), true) { let _ = child.kill();
let _ = child.kill();
}
} }
break; break;
} }
@@ -520,12 +450,9 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1") .env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd { if let Some(ref cwd) = cwd {
c.current_dir(cwd); c.current_dir(cwd);
} }
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()? c.spawn()?
} }
ShellCommandType::Direct(cmd) => { ShellCommandType::Direct(cmd) => {
@@ -535,12 +462,9 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1") .env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd { if let Some(ref cwd) = cwd {
c.current_dir(cwd); c.current_dir(cwd);
} }
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()? c.spawn()?
} }
}; };
@@ -613,24 +537,7 @@ impl CliProcessManager {
locked.error = Some("CLI did not start in time".to_string()); locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness"); log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() { if let Some(child) = child_holder_clone.lock().as_mut() {
#[cfg(unix)] let _ = child.kill();
unsafe {
let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = child.kill();
}
} }
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"})); let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked); Self::emit_status(&app_clone, &locked);

View File

@@ -3,11 +3,8 @@
mod cli_manager; mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus}; use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; use tauri::webview::Webview;
@@ -15,31 +12,11 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use url::Url; use url::Url;
#[cfg(windows)]
use std::ffi::OsStr;
#[cfg(windows)]
use std::iter;
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
#[cfg(windows)]
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[cfg(windows)] #[derive(Clone)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
pub struct AppState { pub struct AppState {
pub manager: CliProcessManager, pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct WakeLockConfig {
display: bool,
idle: bool,
sleep: bool,
} }
#[tauri::command] #[tauri::command]
@@ -58,39 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status()) Ok(state.manager.status())
} }
#[tauri::command]
fn wake_lock_start(
state: tauri::State<AppState>,
config: Option<WakeLockConfig>,
) -> Result<(), String> {
let config = config.unwrap_or(WakeLockConfig {
display: true,
idle: false,
sleep: false,
});
let mut builder = keepawake::Builder::default();
builder
.display(config.display)
.idle(config.idle)
.sleep(config.sleep)
.reason("CodeNomad active session")
.app_name("CodeNomad")
.app_reverse_domain("ai.neuralnomads.codenomad.client");
let wake_lock = builder.create().map_err(|err| err.to_string())?;
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
*state_lock = Some(wake_lock);
Ok(())
}
#[tauri::command]
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
state_lock.take();
Ok(())
}
fn is_dev_mode() -> bool { fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok() cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
} }
@@ -157,22 +101,6 @@ fn emit_folder_drop_event(
} }
} }
#[cfg(windows)]
fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
.encode_wide()
.chain(iter::once(0))
.collect();
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
if result < 0 {
eprintln!("[tauri] failed to set AppUserModelID: {result}");
}
}
#[cfg(not(windows))]
fn set_windows_app_user_model_id() {}
fn main() { fn main() {
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard") let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url)) .on_navigation(|webview, url| intercept_navigation(webview, url))
@@ -181,14 +109,13 @@ fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(navigation_guard) .plugin(navigation_guard)
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),
wake_lock: Mutex::new(None),
}) })
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id();
build_menu(&app.handle())?; build_menu(&app.handle())?;
let dev_mode = is_dev_mode(); let dev_mode = is_dev_mode();
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
@@ -200,12 +127,7 @@ fn main() {
}); });
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
cli_get_status,
cli_restart,
wake_lock_start,
wake_lock_stop
])
.on_menu_event(|app_handle, event| { .on_menu_event(|app_handle, event| {
match event.id().0.as_str() { match event.id().0.as_str() {
// File menu // File menu

View File

@@ -1,8 +1,8 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad", "productName": "CodeNomad",
"version": "0.12.3", "version": "0.1.0",
"identifier": "ai.neuralnomads.codenomad.client", "identifier": "ai.opencode.client",
"build": { "build": {
"beforeDevCommand": "npm run dev:bootstrap", "beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server", "beforeBuildCommand": "npm run bundle:server",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.12.3", "version": "0.12.2",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -18,10 +18,8 @@
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
"@suid/system": "^0.14.0", "@suid/system": "^0.14.0",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-notification": "^2.3.3",
"ansi-sequence-parser": "^1.1.3", "ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3", "debug": "^4.4.3",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
@@ -32,7 +30,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"virtua": "^0.48.8", "tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
@@ -45,4 +43,4 @@
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0" "vite-plugin-solid": "^2.10.0"
} }
} }

View File

@@ -11,8 +11,10 @@ import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2" import InstanceShell from "./components/instance/instance-shell2"
import { SettingsScreen } from "./components/settings-screen" import { SettingsScreen } from "./components/settings-screen"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars" import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
@@ -57,6 +59,7 @@ import { openSettings } from "./stores/settings-screen"
const log = getLogger("actions") const log = getLogger("actions")
const App: Component = () => { const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n() const { t } = useI18n()
const { const {
preferences, preferences,
@@ -68,6 +71,7 @@ const App: Component = () => {
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
@@ -180,6 +184,10 @@ const App: Component = () => {
} }
}) })
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => { createEffect(() => {
initReleaseNotifications() initReleaseNotifications()
}) })
@@ -353,6 +361,7 @@ const App: Component = () => {
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,

View File

@@ -1,8 +1,7 @@
import { createSignal, onMount, Show, createEffect } from "solid-js" import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full" import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { getSharedHighlighter } from "../lib/markdown" import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { escapeHtml } from "../lib/text-render-utils"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"

View File

@@ -1,10 +1,9 @@
import { createMemo, Show, createEffect, onCleanup } from "solid-js" import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid" import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import "@git-diff-view/solid/styles/diff-view-pure.css"
import { disableCache } from "@git-diff-view/core" import { disableCache } from "@git-diff-view/core"
import type { DiffHighlighterLang } from "@git-diff-view/core" import type { DiffHighlighterLang } from "@git-diff-view/core"
import { ErrorBoundary } from "solid-js" import { ErrorBoundary } from "solid-js"
import { getLanguageFromPath } from "../lib/text-render-utils" import { getLanguageFromPath } from "../lib/markdown"
import { normalizeDiffText } from "../lib/diff-utils" import { normalizeDiffText } from "../lib/diff-utils"
import { setCacheEntry } from "../lib/global-cache" import { setCacheEntry } from "../lib/global-cache"
import type { CacheEntryParams } from "../lib/global-cache" import type { CacheEntryParams } from "../lib/global-cache"
@@ -135,4 +134,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
</Show> </Show>
</div> </div>
) )
} }

View File

@@ -13,11 +13,8 @@ import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n" import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen" import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
interface FolderSelectionViewProps { interface FolderSelectionViewProps {
@@ -235,6 +232,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary()) props.onSelectFolder(path, selectedBinary())
} }
const openExternalLink = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
async function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
setFocusMode("new") setFocusMode("new")
@@ -423,7 +425,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1> <h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2"> <div class="mt-3 flex justify-center gap-2">
<a <a
href={GITHUB_URL} href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -431,13 +433,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.github")} title={t("folderSelection.links.github")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
void openExternalUrl(GITHUB_URL, "folder-selection") openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}} }}
> >
<GitHubMarkIcon class="w-4 h-4" /> <GitHubMarkIcon class="w-4 h-4" />
</a> </a>
<a <a
href={GITHUB_URL} href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
@@ -445,7 +447,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.githubStars")} title={t("folderSelection.links.githubStars")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
void openExternalUrl(GITHUB_URL, "folder-selection") openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}} }}
> >
<Star class="w-4 h-4" /> <Star class="w-4 h-4" />
@@ -454,7 +456,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show> </Show>
</a> </a>
<a <a
href={DISCORD_URL} href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -462,7 +464,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.discord")} title={t("folderSelection.links.discord")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
void openExternalUrl(DISCORD_URL, "folder-selection") openExternalLink(
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
)
}} }}
> >
<DiscordSymbolIcon class="w-4 h-4" /> <DiscordSymbolIcon class="w-4 h-4" />

View File

@@ -1,13 +1,11 @@
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface ChangesTabProps { interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -115,23 +113,15 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<Suspense <MonacoDiffViewer
fallback={ scopeKey={scopeKey()}
<div class="file-viewer-empty"> path={String(file().file || "")}
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span> before={String((file() as any).before || "")}
</div> after={String((file() as any).after || "")}
} viewMode={props.diffViewMode()}
> contextMode={props.diffContextMode()}
<LazyMonacoDiffViewer wordWrap={props.diffWordWrapMode()}
scopeKey={scopeKey()} />
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)} )}
</Show> </Show>
</div> </div>
@@ -230,7 +220,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")} overlayAriaLabel="Changes"
/> />
) )
} }

View File

@@ -1,13 +1,11 @@
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client" import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import SplitFilePanel from "../components/SplitFilePanel" import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
const LazyMonacoFileViewer = lazy(() => import SplitFilePanel from "../components/SplitFilePanel"
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
)
interface FilesTabProps { interface FilesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -53,8 +51,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath() const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
const emptyViewerMessage = () => { const emptyViewerMessage = () => {
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading") if (props.browserLoading() && entriesValue === null) return "Loading files..."
return props.t("instanceShell.filesShell.viewerEmpty") return "Select a file to preview"
} }
const renderViewer = () => ( const renderViewer = () => (
@@ -79,15 +77,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
} }
> >
{(payload) => ( {(payload) => (
<Suspense <MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
</Suspense>
)} )}
</Show> </Show>
} }
@@ -101,7 +91,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
} }
> >
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span> <span class="file-viewer-empty-text">Loading</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -123,7 +113,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show> </Show>
<Show when={props.browserLoading() && entriesValue === null}> <Show when={props.browserLoading() && entriesValue === null}>
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div> <div class="p-3 text-xs text-secondary">Loading files...</div>
</Show> </Show>
<For each={sorted}> <For each={sorted}>
@@ -164,7 +154,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</span> </span>
</span> </span>
<Show when={props.browserLoading()}> <Show when={props.browserLoading()}>
<span>{props.t("instanceInfo.loading")}</span> <span>Loading</span>
</Show> </Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show> <Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div> </div>
@@ -190,7 +180,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")} overlayAriaLabel="Files"
/> />
) )
} }

View File

@@ -1,16 +1,14 @@
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface GitChangesTabProps { interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -82,11 +80,11 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}) })
const emptyViewerMessage = createMemo(() => { const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected") if (!hasSession()) return "Select a session to view changes."
const currentEntries = entries() const currentEntries = entries()
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading") if (currentEntries === null) return "Loading git changes…"
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty") if (nonDeleted().length === 0) return "No git changes yet."
return props.t("instanceShell.filesShell.viewerEmpty") return "No file selected."
}) })
const renderContent = (): JSX.Element => { const renderContent = (): JSX.Element => {
@@ -124,14 +122,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<Suspense <MonacoDiffViewer
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={props.scopeKey()} scopeKey={props.scopeKey()}
path={String(file().path || "")} path={String(file().path || "")}
before={String((file() as any).before || "")} before={String((file() as any).before || "")}
@@ -140,8 +131,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()} wordWrap={props.diffWordWrapMode()}
/> />
</Suspense> )}
)}
</Show> </Show>
} }
> >
@@ -154,7 +144,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span> <span class="file-viewer-empty-text">Loading</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -179,7 +169,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span> <span class="text-[10px] text-secondary">deleted</span>
</Show> </Show>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -210,7 +200,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span> <span class="text-[10px] text-secondary">deleted</span>
</Show> </Show>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -230,8 +220,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
<SplitFilePanel <SplitFilePanel
header={ header={
<> <>
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}> <span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span> <span class="file-path-text">{selected?.path || "Git Changes"}</span>
</span> </span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
@@ -274,7 +264,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")} overlayAriaLabel="Git Changes"
/> />
) )
} }

View File

@@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
@@ -7,20 +8,6 @@ import { useI18n } from "../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
type MarkdownModule = typeof import("../lib/markdown")
let markdownModulePromise: Promise<MarkdownModule> | null = null
function loadMarkdownModule(): Promise<MarkdownModule> {
if (!markdownModulePromise) {
markdownModulePromise = import("../lib/markdown").catch((error) => {
markdownModulePromise = null
throw error
})
}
return markdownModulePromise
}
function hashText(value: string): string { function hashText(value: string): string {
let hash = 2166136261 let hash = 2166136261
for (let index = 0; index < value.length; index++) { for (let index = 0; index < value.length; index++) {
@@ -37,45 +24,6 @@ function resolvePartVersion(part: TextPart, text: string): string {
return `text-${hashText(text)}` return `text-${hashText(text)}`
} }
function resolvePartCacheId(part: TextPart, text: string): string {
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
if (partId) {
return partId
}
return `anonymous:${hashText(text)}`
}
function decodeHtmlEntitiesLocally(content: string): string {
if (!content.includes("&") || typeof document === "undefined") {
return content
}
const textarea = document.createElement("textarea")
textarea.innerHTML = content
return textarea.value
}
function escapeHtml(content: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
}
function renderFallbackHtml(content: string): string {
if (!content) {
return ""
}
return escapeHtml(content).replace(/\n/g, "<br />")
}
interface MarkdownProps { interface MarkdownProps {
part: TextPart part: TextPart
instanceId?: string instanceId?: string
@@ -90,8 +38,7 @@ export function Markdown(props: MarkdownProps) {
const { t } = useI18n() const { t } = useI18n()
const [html, setHtml] = createSignal("") const [html, setHtml] = createSignal("")
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
let latestRequestKey = "" let latestRequestedText = ""
let cleanupLanguageListener: (() => void) | undefined
const notifyRendered = () => { const notifyRendered = () => {
Promise.resolve().then(() => props.onRendered?.()) Promise.resolve().then(() => props.onRendered?.())
@@ -100,14 +47,15 @@ export function Markdown(props: MarkdownProps) {
const resolved = createMemo(() => { const resolved = createMemo(() => {
const part = props.part const part = props.part
const rawText = typeof part.text === "string" ? part.text : "" const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntitiesLocally(rawText) const text = decodeHtmlEntities(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light" const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
const cacheId = resolvePartCacheId(part, text) if (!partId) {
throw new Error("Markdown rendering requires a part id")
}
const version = resolvePartVersion(part, text) const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}` return { part, text, themeKey, highlightEnabled, partId, version }
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
}) })
const cacheHandle = useGlobalCache({ const cacheHandle = useGlobalCache({
@@ -115,46 +63,26 @@ export function Markdown(props: MarkdownProps) {
sessionId: () => props.sessionId, sessionId: () => props.sessionId,
scope: "markdown", scope: "markdown",
cacheId: () => { cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved() const { partId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}` return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
}, },
version: () => resolved().version, version: () => resolved().version,
}) })
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => { createEffect(async () => {
const cacheEntry: RenderCache = { const { part, text, themeKey, highlightEnabled, version } = resolved()
text: snapshot.text,
html: renderedHtml,
theme: snapshot.themeKey,
mode: snapshot.version,
}
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
notifyRendered()
}
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => { // Ensure the markdown highlighter theme matches the active UI theme.
const markdown = await loadMarkdownModule() setMarkdownTheme(themeKey === "dark")
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled,
})
if (latestRequestKey === snapshot.requestKey) { latestRequestedText = text
commitCacheEntry(snapshot, rendered)
}
}
createEffect(() => {
const snapshot = resolved()
latestRequestKey = snapshot.requestKey
const cacheMatches = (cache: RenderCache | undefined) => { const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false if (!cache) return false
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version return cache.theme === themeKey && cache.mode === version
} }
const localCache = snapshot.part.renderCache const localCache = part.renderCache
if (localCache && cacheMatches(localCache)) { if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html) setHtml(localCache.html)
notifyRendered() notifyRendered()
@@ -168,82 +96,111 @@ export function Markdown(props: MarkdownProps) {
return return
} }
setHtml(renderFallbackHtml(snapshot.text)) const commitCacheEntry = (renderedHtml: string) => {
notifyRendered() const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
notifyRendered()
}
void renderSnapshot(snapshot).catch((error) => { if (!highlightEnabled) {
log.error("Failed to render markdown:", error) try {
if (latestRequestKey === snapshot.requestKey) { const rendered = await renderMarkdown(text, { suppressHighlight: true })
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
if (latestRequestedText === text) {
commitCacheEntry(rendered)
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
commitCacheEntry(text)
}
} }
}) return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
commitCacheEntry(rendered)
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
commitCacheEntry(text)
}
}
}) })
onMount(() => { onMount(() => {
const handleClick = async (event: Event) => { const handleClick = async (e: Event) => {
const target = event.target as HTMLElement const target = e.target as HTMLElement
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
if (!copyButton) { if (copyButton) {
return e.preventDefault()
const code = copyButton.getAttribute("data-code")
if (code) {
const decodedCode = decodeURIComponent(code)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (copyText) {
if (success) {
copyText.textContent = t("markdown.codeBlock.copy.copied")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
} else {
copyText.textContent = t("markdown.codeBlock.copy.failed")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
}
}
}
} }
event.preventDefault()
const code = copyButton.getAttribute("data-code")
if (!code) {
return
}
const decodedCode = decodeURIComponent(code)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (!copyText) {
return
}
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
} }
containerRef?.addEventListener("click", handleClick) containerRef?.addEventListener("click", handleClick)
let disposed = false const cleanupLanguageListener = onLanguagesLoaded(async () => {
void loadMarkdownModule() if (props.disableHighlight) {
.then((markdown) => { return
if (disposed) { }
return
const { part, text, themeKey, version } = resolved()
setMarkdownTheme(themeKey === "dark")
if (latestRequestedText !== text) {
return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered)
cacheHandle.set(cacheEntry)
notifyRendered()
} }
} catch (error) {
cleanupLanguageListener = markdown.onLanguagesLoaded(() => { log.error("Failed to re-render markdown after language load:", error)
const snapshot = resolved() }
if (!snapshot.highlightEnabled) { })
return
}
latestRequestKey = snapshot.requestKey
void renderSnapshot(snapshot).catch((error) => {
log.error("Failed to re-render markdown after language load:", error)
})
})
})
.catch((error) => {
log.error("Failed to load markdown module:", error)
})
onCleanup(() => { onCleanup(() => {
disposed = true
containerRef?.removeEventListener("click", handleClick) containerRef?.removeEventListener("click", handleClick)
cleanupLanguageListener?.() cleanupLanguageListener()
cleanupLanguageListener = undefined
}) })
}) })
const proseClass = () => "markdown-body"
return ( return (
<div <div
ref={containerRef} ref={containerRef}
class="markdown-body" class={proseClass()}
data-view="markdown" data-view="markdown"
data-part-id={resolved().partId} data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey} data-markdown-theme={resolved().themeKey}

View File

@@ -1,10 +1,9 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import { ArrowBigUp, ArrowBigDown, Loader2, Mic } from "lucide-solid"
import UnifiedPicker from "./unified-picker" import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments" import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
import Kbd from "./kbd" import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
import { agents, executeCustomCommand } from "../stores/sessions" import { agents, executeCustomCommand } from "../stores/sessions"
@@ -14,41 +13,13 @@ import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { preferences } from "../stores/preferences" import { preferences } from "../stores/preferences"
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types" import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
import type { Attachment } from "../types/attachment"
import { usePromptState } from "./prompt-input/usePromptState" import { usePromptState } from "./prompt-input/usePromptState"
import { usePromptAttachments } from "./prompt-input/usePromptAttachments" import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
const log = getLogger("actions") const log = getLogger("actions")
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
if (!text || attachments.length === 0) return []
const usedCounters = new Set<string>()
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
const counter = match?.[1]
if (counter) usedCounters.add(counter)
}
if (usedCounters.size === 0) return []
const consumed = new Set<string>()
for (const attachment of attachments) {
if (!attachment?.id) continue
if (attachment?.source?.type !== "text") continue
const display = attachment.display
if (typeof display !== "string") continue
const match = display.match(pastedDisplayCounterRegex)
if (!match?.[1]) continue
if (usedCounters.has(match[1])) {
consumed.add(attachment.id)
}
}
return Array.from(consumed)
}
export default function PromptInput(props: PromptInputProps) { export default function PromptInput(props: PromptInputProps) {
const { t } = useI18n() const { t } = useI18n()
const [, setIsFocused] = createSignal(false) const [, setIsFocused] = createSignal(false)
@@ -276,12 +247,7 @@ export default function PromptInput(props: PromptInputProps) {
commandName.length > 0 && commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName) getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : "" const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
const resolvedPrompt = isKnownSlashCommand
? resolvedCommandArgs
? `${commandToken} ${resolvedCommandArgs}`
: commandToken
: resolvePastedPlaceholders(text, currentAttachments)
const historyEntry = resolvedPrompt const historyEntry = resolvedPrompt
const refreshHistory = () => recordHistoryEntry(historyEntry) const refreshHistory = () => recordHistoryEntry(historyEntry)
@@ -297,10 +263,6 @@ export default function PromptInput(props: PromptInputProps) {
syncAttachmentCounters("") syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} else { } else {
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
for (const attachmentId of consumedIds) {
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}
syncAttachmentCounters("") syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} }
@@ -320,7 +282,7 @@ export default function PromptInput(props: PromptInputProps) {
await props.onSend(resolvedPrompt, []) await props.onSend(resolvedPrompt, [])
} }
} else if (isKnownSlashCommand) { } else if (isKnownSlashCommand) {
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs) await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
} else { } else {
await props.onSend(resolvedPrompt, currentAttachments) await props.onSend(resolvedPrompt, currentAttachments)
} }
@@ -450,9 +412,45 @@ export default function PromptInput(props: PromptInputProps) {
}) })
const shouldShowOverlay = () => prompt().length === 0 const shouldShowOverlay = () => prompt().length === 0
const voiceInput = usePromptVoiceInput({
prompt,
setPrompt,
getTextarea: () => textareaRef ?? null,
enabled: () => preferences().showPromptVoiceInput,
disabled: () => Boolean(props.disabled),
})
const showVoiceInput = () =>
preferences().showPromptVoiceInput &&
(voiceInput.canUseVoiceInput() || voiceInput.isRecording() || voiceInput.isTranscribing())
const instance = () => getActiveInstance() const instance = () => getActiveInstance()
let voiceButtonPressed = false
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
voiceButtonPressed = true
if (event instanceof PointerEvent) {
const target = event.currentTarget
if (target instanceof HTMLElement) {
try {
target.setPointerCapture(event.pointerId)
} catch {
// no-op
}
}
}
void voiceInput.startRecording()
}
const endVoicePress = () => {
if (!voiceButtonPressed) return
voiceButtonPressed = false
voiceInput.stopRecording()
}
return ( return (
<div class="prompt-input-container"> <div class="prompt-input-container">
<div <div
@@ -594,6 +592,48 @@ export default function PromptInput(props: PromptInputProps) {
</div> </div>
<div class="prompt-input-actions"> <div class="prompt-input-actions">
<Show when={showVoiceInput()}>
<button
type="button"
class={`prompt-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<span class="prompt-voice-timer">{formatVoiceTimer(voiceInput.elapsedMs())}</span>
</Show>
</button>
</Show>
<button <button
type="button" type="button"
class="stop-button" class="stop-button"
@@ -628,3 +668,10 @@ export default function PromptInput(props: PromptInputProps) {
</div> </div>
) )
} }
function formatVoiceTimer(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
}

View File

@@ -0,0 +1,244 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { showAlertDialog } from "../../stores/alerts"
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
import { serverApi } from "../../lib/api-client"
import { useI18n } from "../../lib/i18n"
interface UsePromptVoiceInputOptions {
prompt: Accessor<string>
setPrompt: (value: string) => void
getTextarea: () => HTMLTextAreaElement | null
enabled: Accessor<boolean>
disabled: Accessor<boolean>
}
type VoiceInputState = "idle" | "recording" | "transcribing"
export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const { t } = useI18n()
const [state, setState] = createSignal<VoiceInputState>("idle")
const [elapsedMs, setElapsedMs] = createSignal(0)
let mediaRecorder: MediaRecorder | null = null
let mediaStream: MediaStream | null = null
let timerId: number | undefined
let shouldTranscribe = true
let recordedChunks: Blob[] = []
let recordingStartedAt = 0
createEffect(() => {
void loadSpeechCapabilities()
})
onCleanup(() => {
cleanupMedia(false)
})
const isSupported = () => {
if (typeof window === "undefined") return false
return typeof window.MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia)
}
const canUseVoiceInput = () => {
const capabilities = speechCapabilities()
return Boolean(
options.enabled() &&
isSupported() &&
capabilities?.available &&
capabilities?.configured &&
capabilities?.supportsStt,
)
}
async function toggleRecording(): Promise<void> {
if (state() === "recording") {
stopRecording()
return
}
await startRecording()
}
function stopRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = true
mediaRecorder.stop()
setState("transcribing")
stopTimer()
}
function cancelRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = false
mediaRecorder.stop()
cleanupMedia(false)
}
async function startRecording() {
if (!canUseVoiceInput() || options.disabled() || state() === "transcribing" || state() === "recording") return
if (!isSupported()) {
showAlertDialog(t("promptInput.voiceInput.error.unsupported"), {
title: t("promptInput.voiceInput.error.title"),
variant: "error",
})
return
}
try {
recordedChunks = []
shouldTranscribe = true
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = createRecorder(mediaStream)
mediaRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data)
}
})
mediaRecorder.addEventListener("stop", () => {
void finalizeRecording()
})
recordingStartedAt = Date.now()
setElapsedMs(0)
setState("recording")
startTimer()
mediaRecorder.start()
} catch (error) {
cleanupMedia(false)
showAlertDialog(t("promptInput.voiceInput.error.permission"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
}
async function finalizeRecording() {
const recorder = mediaRecorder
const stream = mediaStream
mediaRecorder = null
mediaStream = null
if (!shouldTranscribe || recordedChunks.length === 0) {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
return
}
const mimeType = recorder?.mimeType || recordedChunks[0]?.type || "audio/webm"
try {
const audioBlob = new Blob(recordedChunks, { type: mimeType })
const transcription = await serverApi.transcribeAudio({
audioBase64: await blobToBase64(audioBlob),
mimeType,
})
if (transcription.text.trim()) {
insertTranscript(transcription.text.trim())
}
} catch (error) {
showAlertDialog(t("promptInput.voiceInput.error.transcribe"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
}
}
function insertTranscript(text: string) {
const current = options.prompt()
const textarea = options.getTextarea()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const before = current.slice(0, start)
const after = current.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length
options.setPrompt(nextValue)
if (textarea) {
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
}, 0)
}
}
function cleanupMedia(resetState = true) {
stopTimer()
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop()
}
mediaRecorder = null
stopTracks(mediaStream)
mediaStream = null
recordedChunks = []
if (resetState) {
setState("idle")
setElapsedMs(0)
}
}
function startTimer() {
stopTimer()
timerId = window.setInterval(() => {
setElapsedMs(Date.now() - recordingStartedAt)
}, 250)
}
function stopTimer() {
if (timerId !== undefined) {
window.clearInterval(timerId)
timerId = undefined
}
}
return {
state,
elapsedMs,
canUseVoiceInput,
startRecording,
stopRecording,
toggleRecording,
cancelRecording,
isRecording: () => state() === "recording",
isTranscribing: () => state() === "transcribing",
buttonTitle: () => {
if (state() === "recording") return t("promptInput.voiceInput.stop.title")
if (state() === "transcribing") return t("promptInput.voiceInput.transcribing.title")
return t("promptInput.voiceInput.start.title")
},
}
}
function createRecorder(stream: MediaStream): MediaRecorder {
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
const supported = candidates.find((candidate) => typeof MediaRecorder.isTypeSupported !== "function" || MediaRecorder.isTypeSupported(candidate))
return supported ? new MediaRecorder(stream, { mimeType: supported }) : new MediaRecorder(stream)
}
function stopTracks(stream: MediaStream | null) {
stream?.getTracks().forEach((track) => track.stop())
}
async function blobToBase64(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer()
const bytes = new Uint8Array(buffer)
let binary = ""
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid" import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js" import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { import {
@@ -13,6 +13,7 @@ import { AppearanceSettingsSection } from "./settings/appearance-settings-sectio
import { NotificationsSettingsSection } from "./settings/notifications-settings-section" import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section"
export const SettingsScreen: Component = () => { export const SettingsScreen: Component = () => {
const { t } = useI18n() const { t } = useI18n()
@@ -21,6 +22,7 @@ export const SettingsScreen: Component = () => {
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") }, { id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") }, { id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") }, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, { id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
]) ])
@@ -30,6 +32,8 @@ export const SettingsScreen: Component = () => {
return <NotificationsSettingsSection /> return <NotificationsSettingsSection />
case "remote": case "remote":
return <RemoteAccessSettingsSection /> return <RemoteAccessSettingsSection />
case "speech":
return <SpeechSettingsSection />
case "opencode": case "opencode":
return <OpenCodeSettingsSection /> return <OpenCodeSettingsSection />
case "appearance": case "appearance":

View File

@@ -24,6 +24,7 @@ export const AppearanceSettingsSection: Component = () => {
toggleUsageMetrics, toggleUsageMetrics,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
@@ -38,10 +39,11 @@ export const AppearanceSettingsSection: Component = () => {
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints, toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
setDiffViewMode, toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion, setThinkingBlocksExpansion,

View File

@@ -0,0 +1,217 @@
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
import { Mic, Volume2 } from "lucide-solid"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech"
import { getLogger } from "../../lib/logger"
const log = getLogger("actions")
type DraftFields = {
apiKey: string
baseUrl: string
sttModel: string
ttsModel: string
ttsVoice: string
}
function createDraftFields(speech: SpeechSettings): DraftFields {
return {
apiKey: speech.apiKey ?? "",
baseUrl: speech.baseUrl ?? "",
sttModel: speech.sttModel,
ttsModel: speech.ttsModel,
ttsVoice: speech.ttsVoice,
}
}
function isDraftEqual(a: DraftFields, b: DraftFields): boolean {
return a.apiKey === b.apiKey && a.baseUrl === b.baseUrl && a.sttModel === b.sttModel && a.ttsModel === b.ttsModel && a.ttsVoice === b.ttsVoice
}
export const SpeechSettingsCard: Component = () => {
const { t } = useI18n()
const { serverSettings, updateSpeechSettings } = useConfig()
const initialDrafts = createDraftFields(serverSettings().speech)
const [isSaving, setIsSaving] = createSignal(false)
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
createEffect(() => {
const speech = serverSettings().speech
const nextDrafts = createDraftFields(speech)
if (!isSaving() && !isDirty()) {
if (!isDraftEqual(drafts(), nextDrafts)) {
setDrafts(nextDrafts)
}
}
})
createEffect(() => {
void loadSpeechCapabilities()
})
const capabilityLabel = () => {
if (speechCapabilitiesLoading()) return t("settings.speech.status.loading")
if (speechCapabilitiesError()) return t("settings.speech.status.error")
return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing")
}
const updateDraft = (key: keyof DraftFields, value: string) => {
setSaveStatus("idle")
setDrafts((current) => ({ ...current, [key]: value }))
}
const isDirty = createMemo(() => {
const speech = serverSettings().speech
const current = drafts()
return (
(current.apiKey || "") !== (speech.apiKey || "") ||
(current.baseUrl || "") !== (speech.baseUrl || "") ||
current.sttModel !== speech.sttModel ||
current.ttsModel !== speech.ttsModel ||
current.ttsVoice !== speech.ttsVoice
)
})
const saveStatusLabel = () => {
if (isSaving()) return t("settings.speech.save.saving")
if (saveStatus() === "saved") return t("settings.speech.save.saved")
if (saveStatus() === "error") return t("settings.speech.save.error")
return t("settings.speech.save.unsaved")
}
async function handleSave() {
if (!isDirty() || isSaving()) return
const current = drafts()
setIsSaving(true)
setSaveStatus("idle")
try {
await updateSpeechSettings({
apiKey: current.apiKey.trim() || undefined,
baseUrl: current.baseUrl.trim() || undefined,
sttModel: current.sttModel.trim() || undefined,
ttsModel: current.ttsModel.trim() || undefined,
ttsVoice: current.ttsVoice.trim() || undefined,
})
await loadSpeechCapabilities(true)
setDrafts({
apiKey: current.apiKey.trim(),
baseUrl: current.baseUrl.trim(),
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
})
setSaveStatus("saved")
} catch (error) {
log.error("Failed to save speech settings", error)
setSaveStatus("error")
} finally {
setIsSaving(false)
}
}
return (
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Volume2 class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.speech.title")}</h3>
<p class="settings-card-subtitle">{t("settings.speech.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.speech.provider.title")}</div>
<div class="settings-toggle-caption">{t("settings.speech.provider.subtitle")}</div>
</div>
<div class="settings-toolbar-inline">
<span class="settings-inline-note">{t("settings.speech.provider.openaiCompatible")}</span>
<span class="settings-inline-note">{capabilityLabel()}</span>
<span class="settings-inline-note">{saveStatusLabel()}</span>
<button
type="button"
class="selector-button selector-button-primary w-auto whitespace-nowrap"
onClick={() => void handleSave()}
disabled={!isDirty() || isSaving()}
>
{isSaving() ? t("settings.speech.save.saving") : t("settings.speech.save.action")}
</button>
</div>
</div>
<Field
label={t("settings.speech.apiKey.title")}
caption={t("settings.speech.apiKey.subtitle")}
value={drafts().apiKey}
onInput={(value) => updateDraft("apiKey", value)}
type="password"
/>
<Field
label={t("settings.speech.baseUrl.title")}
caption={t("settings.speech.baseUrl.subtitle")}
value={drafts().baseUrl}
onInput={(value) => updateDraft("baseUrl", value)}
placeholder={t("settings.speech.baseUrl.placeholder")}
/>
<Field
label={t("settings.speech.sttModel.title")}
caption={t("settings.speech.sttModel.subtitle")}
value={drafts().sttModel}
onInput={(value) => updateDraft("sttModel", value)}
/>
<Field
label={t("settings.speech.ttsModel.title")}
caption={t("settings.speech.ttsModel.subtitle")}
value={drafts().ttsModel}
onInput={(value) => updateDraft("ttsModel", value)}
/>
<Field
label={t("settings.speech.ttsVoice.title")}
caption={t("settings.speech.ttsVoice.subtitle")}
value={drafts().ttsVoice}
onInput={(value) => updateDraft("ttsVoice", value)}
icon={<Mic class="w-3.5 h-3.5 icon-muted flex-shrink-0" />}
/>
<div class="settings-inline-note">{t("settings.speech.help")}</div>
</div>
</div>
)
}
const Field: Component<{
label: string
caption: string
value: string
type?: string
placeholder?: string
onInput: (value: string) => void
icon?: any
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
{props.icon}
<input
type={props.type ?? "text"}
value={props.value}
onInput={(event) => props.onInput(event.currentTarget.value)}
class="selector-input w-full"
placeholder={props.placeholder}
/>
</div>
</div>
)
}
export default SpeechSettingsCard

View File

@@ -0,0 +1,10 @@
import type { Component } from "solid-js"
import SpeechSettingsCard from "./speech-settings-card"
export const SpeechSettingsSection: Component = () => {
return (
<div class="settings-section-stack">
<SpeechSettingsCard />
</div>
)
}

View File

@@ -1,7 +1,7 @@
import type { Accessor, JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
import { escapeHtml } from "../../lib/text-render-utils" import { escapeHtml } from "../../lib/markdown"
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types" import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
type AnsiRenderCache = RenderCache & { hasAnsi: boolean } type AnsiRenderCache = RenderCache & { hasAnsi: boolean }

View File

@@ -1,26 +1,11 @@
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences" import type { DiffViewMode } from "../../stores/preferences"
import { ToolCallDiffViewer } from "../diff-viewer"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath } from "./utils" import { getRelativePath } from "./utils"
import { getCacheEntry } from "../../lib/global-cache" import { getCacheEntry } from "../../lib/global-cache"
const LazyToolCallDiffViewer = lazy(() =>
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
)
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
onMount(() => {
props.onRendered?.()
})
return (
<div class="tool-call-diff-viewer">
<div innerHTML={props.html} />
</div>
)
}
type CacheHandle = { type CacheHandle = {
get<T>(): T | undefined get<T>(): T | undefined
params(): unknown params(): unknown
@@ -116,20 +101,15 @@ export function createDiffContentRenderer(params: {
</button> </button>
</div> </div>
</div> </div>
{cachedHtml ? ( <ToolCallDiffViewer
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} /> diffText={payload.diffText}
) : ( filePath={payload.filePath}
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}> theme={themeKey}
<LazyToolCallDiffViewer mode={diffMode()}
diffText={payload.diffText} cachedHtml={cachedHtml}
filePath={payload.filePath} cacheEntryParams={cacheEntryParams as any}
theme={themeKey} onRendered={handleDiffRendered}
mode={diffMode()} />
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
</Suspense>
)}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )

View File

@@ -1,5 +1,5 @@
import { isRenderableDiffText } from "../../lib/diff-utils" import { isRenderableDiffText } from "../../lib/diff-utils"
import { getLanguageFromPath } from "../../lib/text-render-utils" import { getLanguageFromPath } from "../../lib/markdown"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { DiffPayload } from "./types" import type { DiffPayload } from "./types"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"

View File

@@ -1,5 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX, on } from "solid-js" import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const USER_SCROLL_INTENT_WINDOW_MS = 600 const USER_SCROLL_INTENT_WINDOW_MS = 600
@@ -122,28 +122,55 @@ export interface VirtualFollowListProps<T> {
} }
export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) { export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const getAnchorId = (key: string) => (props.getAnchorId ? props.getAnchorId(key) : key)
const getKeyFromAnchorId = (anchorId: string) => (props.getKeyFromAnchorId ? props.getKeyFromAnchorId(anchorId) : anchorId)
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>() const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>() const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
const [virtuaHandle, setVirtuaHandle] = createSignal<VirtualizerHandle | undefined>() const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
const bottomSentinel = () => bottomSentinelSignal()
const isActive = () => (props.isActive ? props.isActive() : true) const isActive = () => (props.isActive ? props.isActive() : true)
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true) const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true) const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true) const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
const isLoading = () => Boolean(props.loading?.())
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll())) const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
const [activeKey, setActiveKey] = createSignal<string | null>(null) const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [anchorLock, setAnchorLock] = createSignal<{ key: string; block: ScrollLogicalPosition } | null>(null)
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
let userScrollIntentUntil = 0 let containerRef: HTMLDivElement | undefined
let lastUserScrollIntentDirection: "up" | "down" | null = null let shellRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined let pendingScrollFrame: number | null = null
let lastResetKey: string | number | undefined let pendingAnchorScroll: number | null = null
let pendingAnchorCorrectionFrame: number | null = null
let pendingScrollCompensationScheduled = false
let pendingScrollCompensations = new Map<string, number>()
let scrollCompensationGen = 0
let pendingActiveScroll = false
let suppressAutoScrollOnce = false let suppressAutoScrollOnce = false
let pendingInitialScroll = true let pendingInitialScroll = true
let scrollToBottomFrame: number | null = null
let scrollToBottomDelayedFrame: number | null = null
let lastKnownScrollTop = 0
let lastUserScrollIntentDirection: "up" | "down" | null = null
let userScrollIntentUntil = 0
let detachScrollIntentListeners: (() => void) | undefined
let lastResetKey: string | number | undefined
const state: VirtualFollowListState = { const state: VirtualFollowListState = {
autoScroll, autoScroll,
@@ -154,7 +181,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
function markUserScrollIntent(direction?: "up" | "down" | null) { function markUserScrollIntent(direction?: "up" | "down" | null) {
const now = performance.now() const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
if (direction) { if (direction) {
lastUserScrollIntentDirection = direction lastUserScrollIntentDirection = direction
@@ -162,7 +189,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
function hasUserScrollIntent() { function hasUserScrollIntent() {
return performance.now() <= userScrollIntentUntil const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
} }
function attachScrollIntentListeners(element: HTMLDivElement | undefined) { function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
@@ -203,189 +231,670 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
} }
function updateScrollButtons() { function updateScrollIndicatorsFromVisibility() {
const handle = virtuaHandle()
const element = scrollElement()
if (!handle || !element) return
const offset = handle.scrollOffset
const scrollHeight = handle.scrollSize
const clientHeight = element.clientHeight
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
const hasItems = props.items().length > 0 const hasItems = props.items().length > 0
setShowScrollBottomButton(hasItems && !atBottom) const bottomVisible = bottomSentinelVisible()
setShowScrollTopButton(hasItems && !atTop) const topVisible = topSentinelVisible()
setShowScrollBottomButton(hasItems && !bottomVisible)
setShowScrollTopButton(hasItems && !topVisible)
}
// Sync autoScroll state based on scroll position if it was a user scroll function clearScrollToBottomFrames() {
if (hasUserScrollIntent()) { if (scrollToBottomFrame !== null) {
if (atBottom && !autoScroll()) { cancelAnimationFrame(scrollToBottomFrame)
setAutoScroll(true) scrollToBottomFrame = null
} else if (!atBottom && autoScroll()) { }
setAutoScroll(false) if (scrollToBottomDelayedFrame !== null) {
} cancelAnimationFrame(scrollToBottomDelayedFrame)
scrollToBottomDelayedFrame = null
} }
} }
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) { function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
const handle = virtuaHandle() if (!containerRef) return
if (!handle) return if (anchorLock()) {
if (options?.suppressAutoAnchor ?? !immediate) { clearAnchorLock()
}
const sentinel = bottomSentinel()
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
suppressAutoScrollOnce = true suppressAutoScrollOnce = true
} }
handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate }) sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
setAutoScroll(true) setAutoScroll(true)
} }
function scrollToTop(immediate = true) { function requestScrollToBottom(immediate = true) {
const handle = virtuaHandle() if (!isActive()) {
if (!handle) return pendingActiveScroll = true
handle.scrollToIndex(0, { align: "start", smooth: !immediate }) return
}
if (!containerRef || !bottomSentinel()) {
pendingActiveScroll = true
return
}
pendingActiveScroll = false
clearScrollToBottomFrames()
scrollToBottomFrame = requestAnimationFrame(() => {
scrollToBottomFrame = null
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
scrollToBottomDelayedFrame = null
scrollToBottom(immediate)
})
})
}
function resolvePendingActiveScroll() {
if (!pendingActiveScroll) return
if (!isActive()) return
requestScrollToBottom(true)
}
function scrollToTop(immediate = false) {
if (!containerRef) return
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
if (anchorLock()) {
clearAnchorLock()
}
setAutoScroll(false) setAutoScroll(false)
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
if (!isActive()) {
pendingActiveScroll = true
return
}
const sentinel = bottomSentinel()
if (!sentinel) {
pendingActiveScroll = true
return
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
})
}
function clearAnchorLock() {
setAnchorLock(null)
if (pendingAnchorCorrectionFrame !== null) {
cancelAnimationFrame(pendingAnchorCorrectionFrame)
pendingAnchorCorrectionFrame = null
}
}
function computeDesiredOffset(block: ScrollLogicalPosition, container: HTMLElement, anchorRect: DOMRect) {
if (block === "end") {
return Math.max(0, container.clientHeight - anchorRect.height)
}
if (block === "center") {
return Math.max(0, container.clientHeight / 2 - anchorRect.height / 2)
}
// Default to start.
return 0
}
function applyAnchorCorrection() {
const lock = anchorLock()
if (!lock) return
if (autoScroll()) return
if (!containerRef) return
if (typeof document === "undefined") return
const anchorId = getAnchorId(lock.key)
const anchor = document.getElementById(anchorId)
if (!anchor) return
const containerRect = containerRef.getBoundingClientRect()
const anchorRect = anchor.getBoundingClientRect()
const currentOffset = anchorRect.top - containerRect.top
const desiredOffset = computeDesiredOffset(lock.block, containerRef, anchorRect)
const delta = currentOffset - desiredOffset
if (!Number.isFinite(delta) || Math.abs(delta) < 0.5) {
return
}
const nextTop = containerRef.scrollTop + delta
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
containerRef.scrollTop = Math.min(maxScrollTop, Math.max(0, nextTop))
}
function scheduleAnchorCorrection() {
if (pendingAnchorCorrectionFrame !== null) return
pendingAnchorCorrectionFrame = requestAnimationFrame(() => {
pendingAnchorCorrectionFrame = null
applyAnchorCorrection()
})
}
function handleContentRendered() {
if (autoScroll() && !anchorLock()) {
scheduleAutoPinToBottom()
return
}
if (anchorLock() && !autoScroll()) {
scheduleAnchorCorrection()
return
}
} }
function handleScroll() { function handleScroll() {
const isUserScroll = hasUserScrollIntent() if (!containerRef) return
if (isUserScroll) { if (pendingScrollFrame !== null) {
if (lastUserScrollIntentDirection === "up" && autoScroll()) { cancelAnimationFrame(pendingScrollFrame)
setAutoScroll(false)
}
} }
updateScrollButtons() const isUserScroll = hasUserScrollIntent()
props.onScroll?.() pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
if (!containerRef) return
const previousScrollTop = lastKnownScrollTop
const currentScrollTop = containerRef.scrollTop
const deltaScrollTop = currentScrollTop - previousScrollTop
if (currentScrollTop !== lastKnownScrollTop) {
lastKnownScrollTop = currentScrollTop
}
const atBottom = bottomSentinelVisible()
// Find active key (roughly the first visible item) const beforeAutoScroll = autoScroll()
const handle = virtuaHandle()
if (handle) { const inferredDirection: "up" | "down" | null =
const start = handle.findItemIndex(handle.scrollOffset) lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null)
const items = props.items()
if (items[start]) { // If the user scrolls manually, exit key-anchored mode.
const key = props.getKey(items[start], start) if (isUserScroll && anchorLock()) {
if (key !== activeKey()) { clearAnchorLock()
setActiveKey(key) }
props.onActiveKeyChange?.(key)
if (isUserScroll) {
// If the user is actively scrolling upward, exit follow-to-bottom mode
// immediately. The bottom sentinel can remain "visible" for a short
// distance due to its observer margin, which otherwise keeps autoScroll
// enabled and makes the list feel stuck.
if (inferredDirection === "up" && deltaScrollTop < -0.5 && autoScroll()) {
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
setAutoScroll(false)
}
// Do not re-enable follow mode while the user's current scroll intent
// is upward. This prevents transient anchor/pin scrolls from pulling
// the list back into autoScroll(true).
if (inferredDirection !== "up") {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
} else if (!atBottom && autoScroll()) {
// If the user is scrolling up and we are no longer at the bottom,
// ensure follow mode is disabled.
setAutoScroll(false)
} }
} }
props.onScroll?.()
})
}
function setContainerRef(element: HTMLDivElement | null) {
containerRef = element || undefined
setScrollElement(containerRef)
props.onScrollElementChange?.(containerRef)
attachScrollIntentListeners(containerRef)
lastKnownScrollTop = containerRef?.scrollTop ?? 0
lastUserScrollIntentDirection = null
if (!containerRef) {
return
}
resolvePendingActiveScroll()
}
function scheduleScrollCompensation(key: string, delta: number) {
if (!containerRef) return
if (!delta || !Number.isFinite(delta)) return
if (typeof document === "undefined") return
// Only compensate while the user scrolls upward (testing default).
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") return
if (autoScroll() || anchorLock()) return
const anchorId = getAnchorId(key)
const anchor = document.getElementById(anchorId)
if (!anchor) return
const containerRect = containerRef.getBoundingClientRect()
const rect = anchor.getBoundingClientRect()
// Determine whether the item was fully above the viewport *before* the
// height delta applied. Items can expand downward into the viewport; in that
// case we still need to compensate to keep existing visible content stable.
const bottomAfter = rect.bottom
const bottomBefore = bottomAfter - delta
const wasAboveViewport = bottomBefore < containerRect.top
if (!wasAboveViewport) return
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
pendingScrollCompensations.set(key, next)
if (pendingScrollCompensationScheduled) return
pendingScrollCompensationScheduled = true
const gen = scrollCompensationGen
// Flush in a microtask so compensation lands before the next paint.
queueMicrotask(() => {
if (gen !== scrollCompensationGen) return
pendingScrollCompensationScheduled = false
if (!containerRef) return
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") {
pendingScrollCompensations = new Map()
return
}
if (autoScroll() || anchorLock()) {
pendingScrollCompensations = new Map()
return
}
let applied = 0
let count = 0
for (const pendingDelta of pendingScrollCompensations.values()) {
if (!pendingDelta) continue
applied += pendingDelta
count += 1
}
pendingScrollCompensations = new Map()
if (!applied) return
const before = containerRef.scrollTop
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
const nextTop = Math.min(maxScrollTop, Math.max(0, before + applied))
if (nextTop !== before) {
containerRef.scrollTop = nextTop
lastKnownScrollTop = nextTop
}
})
}
let pendingAutoPin = false
let pendingAutoPinFrame: number | null = null
function clearPendingAutoPinFrame() {
if (pendingAutoPinFrame !== null) {
cancelAnimationFrame(pendingAutoPinFrame)
pendingAutoPinFrame = null
} }
} }
function applyAutoPinToBottom() {
if (!containerRef) return false
if (!autoScroll()) return false
if (anchorLock()) return false
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
if (containerRef.scrollTop !== maxScrollTop) {
containerRef.scrollTop = maxScrollTop
lastKnownScrollTop = maxScrollTop
}
return true
}
function scheduleAutoPinToBottom() {
if (!containerRef) return
if (pendingAutoPin) return
pendingAutoPin = true
clearPendingAutoPinFrame()
const gen = scrollCompensationGen
// Flush in a microtask so adjustments land before the next paint,
// then re-apply on the next two frames to catch deferred layout.
queueMicrotask(() => {
if (gen !== scrollCompensationGen) return
pendingAutoPin = false
if (!applyAutoPinToBottom()) return
pendingAutoPinFrame = requestAnimationFrame(() => {
pendingAutoPinFrame = null
if (gen !== scrollCompensationGen) return
if (!applyAutoPinToBottom()) return
pendingAutoPinFrame = requestAnimationFrame(() => {
pendingAutoPinFrame = null
if (gen !== scrollCompensationGen) return
applyAutoPinToBottom()
})
})
})
}
function setShellRef(element: HTMLDivElement | null) {
shellRef = element || undefined
setShellElement(shellRef)
props.onShellElementChange?.(shellRef)
}
function setBottomSentinel(element: HTMLDivElement | null) {
setBottomSentinelSignal(element)
resolvePendingActiveScroll()
}
const api: VirtualFollowListApi = { const api: VirtualFollowListApi = {
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true), scrollToTop: (opts) => scrollToTop(Boolean(opts?.immediate)),
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }), scrollToBottom: (opts) => scrollToBottom(Boolean(opts?.immediate), { suppressAutoAnchor: opts?.suppressAutoAnchor }),
scrollToKey: (key, opts) => { scrollToKey: (key, opts) => {
const index = props.items().findIndex((item, i) => props.getKey(item, i) === key) if (typeof document === "undefined") return
if (index === -1) return const anchorId = getAnchorId(key)
const behavior = opts?.behavior ?? "smooth"
const block = opts?.block ?? "start"
const nextAutoScroll = opts?.setAutoScroll ?? false const nextAutoScroll = opts?.setAutoScroll ?? false
setAutoScroll(nextAutoScroll) setAutoScroll(nextAutoScroll)
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" }) if (!nextAutoScroll) {
}, if (anchorLock()) {
notifyContentRendered: () => { clearAnchorLock()
if (autoScroll()) { }
scrollToBottom(true) setAnchorLock({ key, block })
} else {
if (anchorLock()) {
clearAnchorLock()
}
} }
const first = document.getElementById(anchorId)
first?.scrollIntoView({ block, behavior })
// When using virtualization, the placeholder height can be stale until the
// item mounts/measures. Re-run scrollIntoView() on the next frame to
// stabilize the final position.
requestAnimationFrame(() => {
const second = document.getElementById(anchorId)
second?.scrollIntoView({ block, behavior })
})
}, },
notifyContentRendered: () => handleContentRendered(),
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)), setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
getAutoScroll: () => autoScroll(), getAutoScroll: () => autoScroll(),
getScrollElement: () => scrollElement(), getScrollElement: () => scrollElement(),
getShellElement: () => shellElement(), getShellElement: () => shellElement(),
} }
createEffect(() => props.registerApi?.(api)) createEffect(() => {
createEffect(() => props.registerState?.(state)) props.registerApi?.(api)
})
// Handle autoScroll (Follow) on items change createEffect(() => {
createEffect(on(() => props.items().length, (len, prevLen) => { props.registerState?.(state)
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) { })
requestAnimationFrame(() => scrollToBottom(true))
createEffect(() => {
const nextKey = props.resetKey?.()
if (nextKey === undefined) return
if (lastResetKey === undefined) {
lastResetKey = nextKey
return
} }
suppressAutoScrollOnce = false
}, { defer: true }))
// Handle followToken change
createEffect(on(() => props.followToken?.(), () => {
if (autoScroll()) {
scrollToBottom(true)
}
}, { defer: true }))
// Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return if (nextKey === lastResetKey) return
lastResetKey = nextKey lastResetKey = nextKey
setAutoScroll(initialAutoScroll())
pendingInitialScroll = true
}))
// Initial scroll and session activation // Reset internal state when consumers swap datasets (e.g. session switch).
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (pendingAnchorCorrectionFrame !== null) {
cancelAnimationFrame(pendingAnchorCorrectionFrame)
pendingAnchorCorrectionFrame = null
}
clearScrollToBottomFrames()
scrollCompensationGen += 1
pendingScrollCompensationScheduled = false
pendingScrollCompensations = new Map()
pendingAutoPin = false
clearPendingAutoPinFrame()
suppressAutoScrollOnce = false
pendingActiveScroll = false
pendingInitialScroll = true
setAnchorLock(null)
setActiveKey(null)
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setTopSentinelVisible(true)
setBottomSentinelVisible(true)
setAutoScroll(Boolean(initialAutoScroll()))
lastKnownScrollTop = containerRef?.scrollTop ?? 0
lastUserScrollIntentDirection = null
})
let lastActiveState = false
createEffect(() => { createEffect(() => {
const active = isActive() const active = isActive()
if (!active) return if (active) {
if (pendingInitialScroll && props.items().length > 0) { resolvePendingActiveScroll()
pendingInitialScroll = false if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) {
if (initialScrollToBottom()) { requestScrollToBottom(true)
scrollToBottom(true)
// When switching back to a cached session pane, items can mount/measure
// after the initial scroll jump. Re-pin once layout settles so the
// viewport stays at the bottom.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scheduleAutoPinToBottom()
})
})
} }
} else if (autoScroll() && scrollToBottomOnActivate()) { } else if (autoScroll() && scrollToBottomOnActivate()) {
scrollToBottom(true) pendingActiveScroll = true
}
lastActiveState = active
})
createEffect(() => {
const loading = isLoading()
if (loading) {
// Keep the initial scroll pending while loading so we can
// anchor to the bottom as soon as items appear.
pendingInitialScroll = true
}
if (!pendingInitialScroll) return
const container = scrollElement()
const sentinel = bottomSentinel()
if (!container || !sentinel || props.items().length === 0) return
if (!initialScrollToBottom()) {
// An outer component is responsible for restoring scroll.
pendingInitialScroll = false
return
}
// Ensure we're in follow-to-bottom mode for the initial position.
if (anchorLock()) {
clearAnchorLock()
}
setAutoScroll(true)
pendingInitialScroll = false
// Scroll synchronously so the first paint prefers bottom content.
scrollToBottom(true)
})
let previousFollowToken: string | number | undefined
createEffect(() => {
const token = props.followToken?.()
if (token === undefined) {
previousFollowToken = token
return
}
if (previousFollowToken === undefined) {
previousFollowToken = token
return
}
if (token === previousFollowToken) {
return
}
previousFollowToken = token
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
if (autoScroll()) {
scheduleAutoPinToBottom()
return
}
if (anchorLock() && !autoScroll()) {
scheduleAnchorCorrection()
} }
}) })
return ( // Drop anchor lock if the anchored key is removed.
<div class="virtual-follow-list-shell" ref={shellElement => { createEffect(() => {
setShellElement(shellElement) const lock = anchorLock()
props.onShellElementChange?.(shellElement) if (!lock) return
}}> const keys = props.items().map((item, idx) => props.getKey(item, idx))
<div if (!keys.includes(lock.key)) {
class="message-stream" clearAnchorLock()
ref={el => { }
setScrollElement(el) })
props.onScrollElementChange?.(el)
attachScrollIntentListeners(el)
}}
onMouseUp={props.onMouseUp}
onClick={props.onClick}
>
<Show when={props.renderBeforeItems}>
{props.renderBeforeItems!()}
</Show>
<Virtualizer
ref={setVirtuaHandle}
scrollRef={scrollElement()}
data={props.items()}
bufferSize={props.overscanPx ?? 400}
onScroll={handleScroll}
>
{(item, index) => props.renderItem(item, index())}
</Virtualizer>
</div>
<Show when={props.renderOverlay}> createEffect(() => {
<div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div> if (props.items().length === 0) {
</Show> setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setAutoScroll(true)
return
}
updateScrollIndicatorsFromVisibility()
})
<Show when={props.renderControls}> createEffect(() => {
<div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div> const container = scrollElement()
</Show> const topTarget = topSentinel()
const bottomTarget = bottomSentinel()
if (!container || !topTarget || !bottomTarget) return
if (typeof IntersectionObserver === "undefined") return
<Show const margin = props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX
when={
!props.renderControls && const observer = new IntersectionObserver(
(showScrollTopButton() || showScrollBottomButton()) && (entries) => {
props.scrollToTopAriaLabel && let visibilityChanged = false
props.scrollToBottomAriaLabel for (const entry of entries) {
if (entry.target === topTarget) {
setTopSentinelVisible(entry.isIntersecting)
visibilityChanged = true
} else if (entry.target === bottomTarget) {
setBottomSentinelVisible(entry.isIntersecting)
visibilityChanged = true
}
} }
> if (visibilityChanged) {
updateScrollIndicatorsFromVisibility()
}
},
{ root: container, threshold: 0, rootMargin: `${margin}px 0px ${margin}px 0px` },
)
observer.observe(topTarget)
observer.observe(bottomTarget)
onCleanup(() => observer.disconnect())
})
createEffect(() => {
const container = scrollElement()
const items = props.items()
if (!container || items.length === 0) return
if (typeof document === "undefined") return
if (typeof IntersectionObserver === "undefined") return
const observer = new IntersectionObserver(
(entries) => {
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
best = entry
}
}
if (best) {
const anchorId = (best.target as HTMLElement).id
const key = getKeyFromAnchorId(anchorId)
setActiveKey((current) => (current === key ? current : key))
}
},
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
)
const anchorIds = items.map((item, idx) => getAnchorId(props.getKey(item, idx)))
anchorIds.forEach((anchorId) => {
const anchor = document.getElementById(anchorId)
if (anchor) observer.observe(anchor)
})
onCleanup(() => observer.disconnect())
})
createEffect(() => {
const key = activeKey()
props.onActiveKeyChange?.(key)
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
}
if (pendingAnchorCorrectionFrame !== null) {
cancelAnimationFrame(pendingAnchorCorrectionFrame)
}
scrollCompensationGen += 1
pendingScrollCompensationScheduled = false
pendingScrollCompensations = new Map()
clearPendingAutoPinFrame()
clearScrollToBottomFrames()
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
}
})
const controls = () => {
if (props.renderControls) {
return props.renderControls(state, api)
}
// Avoid hardcoded user-visible strings; require consumers to supply
// localized aria labels when using the default controls.
if (!props.scrollToTopAriaLabel || !props.scrollToBottomAriaLabel) {
return null
}
const labelTop = props.scrollToTopAriaLabel()
const labelBottom = props.scrollToBottomAriaLabel()
return (
<Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper"> <div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}> <Show when={showScrollTopButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={props.scrollToTopAriaLabel!()}> <button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={labelTop}>
<span class="message-scroll-icon" aria-hidden="true"> <span class="message-scroll-icon" aria-hidden="true">
</span> </span>
</button> </button>
</Show> </Show>
<Show when={showScrollBottomButton()}> <Show when={showScrollBottomButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label={props.scrollToBottomAriaLabel!()}> <button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label={labelBottom}
>
<span class="message-scroll-icon" aria-hidden="true"> <span class="message-scroll-icon" aria-hidden="true">
</span> </span>
@@ -393,6 +902,71 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
</Show> </Show>
</div> </div>
</Show> </Show>
)
}
return (
<div class="message-stream-shell" ref={setShellRef}>
<div
class="message-stream"
ref={setContainerRef}
onScroll={handleScroll}
onMouseUp={(event) => props.onMouseUp?.(event)}
onClick={(event) => props.onClick?.(event)}
>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
{props.renderBeforeItems?.()}
<Index each={props.items()}>
{(item, index) => {
const key = () => props.getKey(item(), index)
const anchorId = () => getAnchorId(key())
const overscanPx = props.overscanPx ?? 800
const suspendMeasurements = () => measurementsSuspended() || !isActive()
const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
return (
<VirtualItem
id={anchorId()}
cacheKey={key()}
scrollContainer={scrollElement}
threshold={overscanPx}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={itemVirtualizationEnabled}
suspendMeasurements={suspendMeasurements}
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
const delta = nextHeight - previousHeight
// Follow mode: keep the viewport pinned to the bottom as
// items mount/measure and change height.
if (delta && autoScroll() && !anchorLock()) {
scheduleAutoPinToBottom()
return
}
// Key-anchored mode: keep the target key in view when
// items above it mount/measure and shift layout.
if (anchorLock() && !autoScroll()) {
scheduleAnchorCorrection()
return
}
// Free-scroll mode: if items above the viewport change height
// while scrolling upward, compensate scrollTop so visible
// content stays stable.
if (delta) {
if (meta.isStaleCacheCorrection) return
scheduleScrollCompensation(key(), delta)
}
}}
>{() => props.renderItem(item(), index)}</VirtualItem>
)
}}
</Index>
<div ref={setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
</div>
{controls()}
{props.renderOverlay?.()}
</div> </div>
) )
} }

View File

@@ -0,0 +1,492 @@
import { JSX, Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 400
const VISIBILITY_BUFFER_PX = 0
type ObserverRoot = Element | Document | null
type IntersectionCallback = (entry: IntersectionObserverEntry) => void
interface SharedObserver {
observer: IntersectionObserver
listeners: Map<Element, Set<IntersectionCallback>>
}
const NULL_ROOT_KEY = "__null__"
const rootIds = new WeakMap<Element | Document, number>()
let sharedRootId = 0
const sharedObservers = new Map<string, SharedObserver>()
function getRootKey(root: ObserverRoot, margin: number): string {
if (!root) {
return `${NULL_ROOT_KEY}:${margin}`
}
let id = rootIds.get(root)
if (id === undefined) {
id = ++sharedRootId
rootIds.set(root, id)
}
return `${id}:${margin}`
}
function createSharedObserver(root: ObserverRoot, margin: number): SharedObserver {
const listeners = new Map<Element, Set<IntersectionCallback>>()
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const callbacks = listeners.get(entry.target as Element)
if (!callbacks) return
callbacks.forEach((fn) => fn(entry))
})
},
{
root: root ?? undefined,
rootMargin: `${margin}px 0px ${margin}px 0px`,
},
)
return { observer, listeners }
}
function shouldRenderEntry(entry: IntersectionObserverEntry) {
const rootBounds = entry.rootBounds
if (!rootBounds) {
return entry.isIntersecting
}
// Above the root: compare bottom edge to root top.
if (entry.boundingClientRect.bottom < rootBounds.top) {
const distance = rootBounds.top - entry.boundingClientRect.bottom
return distance <= VISIBILITY_BUFFER_PX
}
// Below the root: compare top edge to root bottom.
if (entry.boundingClientRect.top > rootBounds.bottom) {
const distance = entry.boundingClientRect.top - rootBounds.bottom
return distance <= VISIBILITY_BUFFER_PX
}
// Overlapping the root bounds.
return true
}
function getViewportRect(): { top: number; bottom: number } {
if (typeof window === "undefined") {
return { top: 0, bottom: 0 }
}
return { top: 0, bottom: window.innerHeight }
}
function isRenderableRoot(root: ObserverRoot): boolean {
if (!root) return true
if (root instanceof Document) return true
if (typeof window === "undefined") return false
const element = root as Element
const style = window.getComputedStyle(element as Element)
if (style.display === "none" || style.visibility === "hidden") {
return false
}
const rect = (element as Element).getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
function shouldRenderByRects(params: {
wrapperRect: DOMRect
rootRect: { top: number; bottom: number }
margin: number
}): boolean {
const { wrapperRect, rootRect, margin } = params
const threshold = margin + VISIBILITY_BUFFER_PX
// Above the root: compare bottom edge to root top.
if (wrapperRect.bottom < rootRect.top) {
const distance = rootRect.top - wrapperRect.bottom
return distance <= threshold
}
// Below the root: compare top edge to root bottom.
if (wrapperRect.top > rootRect.bottom) {
const distance = wrapperRect.top - rootRect.bottom
return distance <= threshold
}
return true
}
function subscribeToSharedObserver(
target: Element,
root: ObserverRoot,
margin: number,
callback: IntersectionCallback,
): () => void {
if (typeof IntersectionObserver === "undefined") {
callback({ isIntersecting: true } as IntersectionObserverEntry)
return () => {}
}
const key = getRootKey(root, margin)
let shared = sharedObservers.get(key)
if (!shared) {
shared = createSharedObserver(root, margin)
sharedObservers.set(key, shared)
}
let targetCallbacks = shared.listeners.get(target)
if (!targetCallbacks) {
targetCallbacks = new Set()
shared.listeners.set(target, targetCallbacks)
shared.observer.observe(target)
}
targetCallbacks.add(callback)
return () => {
const current = shared?.listeners.get(target)
if (current) {
current.delete(callback)
if (current.size === 0) {
shared?.listeners.delete(target)
shared?.observer.unobserve(target)
}
}
if (shared && shared.listeners.size === 0) {
shared.observer.disconnect()
sharedObservers.delete(key)
}
}
}
interface VirtualItemProps {
cacheKey: string
children: JSX.Element | (() => JSX.Element)
scrollContainer?: Accessor<HTMLElement | undefined | null>
threshold?: number
minPlaceholderHeight?: number
class?: string
contentClass?: string
placeholderClass?: string
virtualizationEnabled?: Accessor<boolean>
forceVisible?: Accessor<boolean>
suspendMeasurements?: Accessor<boolean>
onMeasured?: () => void
onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void
id?: string
}
export interface VirtualItemHeightChangeMeta {
source: "initial-visible-measure" | "resize"
previousCachedHeight: number | null
isStaleCacheCorrection: boolean
wasHidden: boolean
}
export default function VirtualItem(props: VirtualItemProps) {
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
const cachedHeight = sizeCache.get(props.cacheKey)
const fallbackPlaceholderHeight = () => props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
// Default to hidden until we can determine visibility.
// This avoids keeping heavy DOM alive when IntersectionObserver
// doesn't fire (common for hidden/zero-sized scroll roots).
const [isIntersecting, setIsIntersecting] = createSignal(false)
// Keep measuredHeight aligned with the *effective layout height* while hidden.
// When content first mounts, onHeightChange deltas should reflect the DOM's
// placeholder height (not 0), otherwise scroll compensation can overshoot.
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
let pendingVisibility: boolean | null = null
let visibilityFrame: number | null = null
let awaitingVisibleMeasurement = true
let lastMeasurementWhileHidden = true
const flushVisibility = () => {
if (visibilityFrame !== null) {
cancelAnimationFrame(visibilityFrame)
visibilityFrame = null
}
if (pendingVisibility !== null) {
setIsIntersecting(pendingVisibility)
pendingVisibility = null
}
}
const queueVisibility = (nextValue: boolean) => {
pendingVisibility = nextValue
if (visibilityFrame !== null) return
visibilityFrame = requestAnimationFrame(() => {
visibilityFrame = null
if (pendingVisibility !== null) {
setIsIntersecting(pendingVisibility)
pendingVisibility = null
}
})
}
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
const forceVisible = () => Boolean(props.forceVisible?.())
const shouldHideContent = createMemo(() => {
if (forceVisible()) return false
if (!virtualizationEnabled()) return false
return !isIntersecting()
})
let wrapperRef: HTMLDivElement | undefined
let contentRef: HTMLDivElement | undefined
let resizeObserver: ResizeObserver | undefined
let intersectionCleanup: (() => void) | undefined
function cleanupResizeObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = undefined
}
}
function scheduleVisibleMeasurements() {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
queueMicrotask(() => {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
updateMeasuredHeight()
setupResizeObserver()
})
}
function cleanupIntersectionObserver() {
if (intersectionCleanup) {
intersectionCleanup()
intersectionCleanup = undefined
}
}
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
return
}
const before = measuredHeight()
const normalized = nextHeight
const previousCachedHeight = sizeCache.get(props.cacheKey) ?? null
const previous = previousCachedHeight ?? measuredHeight()
const measurementMeta: VirtualItemHeightChangeMeta = {
source: meta?.source ?? "resize",
previousCachedHeight,
isStaleCacheCorrection:
(meta?.source ?? "resize") === "initial-visible-measure" &&
previousCachedHeight !== null &&
normalized > 0 &&
Math.abs(normalized - previousCachedHeight) > 1,
wasHidden: meta?.wasHidden ?? shouldHideContent(),
}
// Only keep the previous measurement when the element reports 0 height.
// Allow shrinkage so placeholder height matches real content height;
// keeping the max height can cause mount/unmount jitter near the
// virtualization boundary.
const shouldKeepPrevious = previous > 0 && normalized === 0
if (shouldKeepPrevious) {
if (!hasReportedMeasurement) {
hasReportedMeasurement = true
props.onMeasured?.()
}
sizeCache.set(props.cacheKey, previous)
setMeasuredHeight(previous)
if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta)
return
}
if (normalized > 0) {
sizeCache.set(props.cacheKey, normalized)
if (!hasReportedMeasurement) {
hasReportedMeasurement = true
props.onMeasured?.()
}
}
setMeasuredHeight(normalized)
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
}
function updateMeasuredHeight() {
if (!contentRef) return
if (measurementsSuspended()) return
// Prefer subpixel-accurate height for scroll compensation.
// offsetHeight rounds to integers which can accumulate error.
const rect = contentRef.getBoundingClientRect()
const next = Math.max(0, Math.round(rect.height * 2) / 2)
const currentMeasured = measuredHeight()
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
const wasHidden = lastMeasurementWhileHidden
if (measurementSource === "initial-visible-measure") {
awaitingVisibleMeasurement = false
lastMeasurementWhileHidden = false
}
if (next === currentMeasured) return
persistMeasurement(next, { source: measurementSource, wasHidden })
}
function setupResizeObserver() {
if (!contentRef || measurementsSuspended()) return
cleanupResizeObserver()
if (typeof ResizeObserver === "undefined") {
updateMeasuredHeight()
return
}
resizeObserver = new ResizeObserver(() => {
if (measurementsSuspended()) return
updateMeasuredHeight()
})
resizeObserver.observe(contentRef)
}
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
cleanupIntersectionObserver()
if (!wrapperRef) {
setIsIntersecting(false)
return
}
if (typeof IntersectionObserver === "undefined") {
setIsIntersecting(true)
return
}
const margin = props.threshold ?? DEFAULT_MARGIN_PX
// If the scroll root is hidden / 0x0, IntersectionObserver can report
// `isIntersecting` in unexpected ways (often "true" with null rootBounds),
// which keeps heavy DOM alive in background tabs.
//
// In that state, force-hide and skip attaching the observer. When the
// pane becomes visible again, VirtualItem will re-run this setup and
// re-attach the observer.
const renderable = isRenderableRoot(targetRoot)
if (!renderable) {
setIsIntersecting(false)
return
}
// Avoid doing an eager geometry read here.
// During large list hydration / initial layout, wrapper rects can be
// transiently 0/incorrect and cause many offscreen items to mount.
// Rely on the observer callback (which we harden below) to determine
// visibility.
const wrapperEl = wrapperRef
intersectionCleanup = subscribeToSharedObserver(wrapperEl, targetRoot, margin, (entry) => {
// IntersectionObserver can produce transient false-positives during pane
// activation/layout transitions (e.g. `isIntersecting: true` for items far
// outside the scroll root). For element roots, prefer explicit rect math.
if (targetRoot && !(targetRoot instanceof Document)) {
// When rootBounds is null we cannot trust the entry; treat as hidden.
if (entry.rootBounds === null) {
queueVisibility(false)
return
}
try {
const rootRect = (targetRoot as Element).getBoundingClientRect()
const visible = shouldRenderByRects({
wrapperRect: wrapperEl.getBoundingClientRect(),
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
margin,
})
queueVisibility(visible)
return
} catch {
// Fall through to the entry-based heuristic.
}
}
const nextVisible = shouldRenderEntry(entry)
queueVisibility(nextVisible)
})
}
function setWrapperRef(element: HTMLDivElement | null) {
wrapperRef = element ?? undefined
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
}
function setContentRef(element: HTMLDivElement | null) {
contentRef = element ?? undefined
if (contentRef) {
queueMicrotask(() => {
if (shouldHideContent() || measurementsSuspended()) return
updateMeasuredHeight()
setupResizeObserver()
})
} else {
cleanupResizeObserver()
}
}
createEffect(() => {
const hidden = shouldHideContent()
if (hidden) {
awaitingVisibleMeasurement = true
lastMeasurementWhileHidden = true
}
if (hidden || measurementsSuspended()) {
cleanupResizeObserver()
}
if (!hidden && !measurementsSuspended() && contentRef) {
scheduleVisibleMeasurements()
}
})
createEffect(() => {
const key = props.cacheKey
const cached = sizeCache.get(key)
if (cached !== undefined) {
setMeasuredHeight(cached)
} else {
setMeasuredHeight(fallbackPlaceholderHeight())
}
})
createEffect(() => {
measurementsSuspended()
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
})
const placeholderHeight = createMemo(() => {
const seenHeight = measuredHeight()
if (seenHeight > 0) {
return seenHeight
}
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
})
onCleanup(() => {
cleanupResizeObserver()
cleanupIntersectionObserver()
flushVisibility()
})
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
const contentClass = () => {
const classes = ["virtual-item-content", props.contentClass]
if (shouldHideContent()) {
classes.push("virtual-item-content-hidden")
}
return classes.filter(Boolean).join(" ")
}
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
const lazyContent = createMemo<JSX.Element | null>(() => {
if (shouldHideContent()) return null
return resolveContent()
})
return (
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
<div
class={placeholderClass()}
style={{
width: "100%",
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
}}
>
<div ref={setContentRef} class={contentClass()}>
{lazyContent()}
</div>
</div>
</div>
)
}

View File

@@ -7,6 +7,9 @@ import type {
FileSystemCreateFolderResponse, FileSystemCreateFolderResponse,
FileSystemListResponse, FileSystemListResponse,
InstanceData, InstanceData,
SpeechCapabilitiesResponse,
SpeechSynthesisResponse,
SpeechTranscriptionResponse,
ServerMeta, ServerMeta,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceDescriptor, WorkspaceDescriptor,
@@ -235,6 +238,27 @@ export const serverApi = {
body: JSON.stringify({ path }), body: JSON.stringify({ path }),
}) })
}, },
fetchSpeechCapabilities(): Promise<SpeechCapabilitiesResponse> {
return request<SpeechCapabilitiesResponse>("/api/speech/capabilities")
},
transcribeAudio(payload: {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}): Promise<SpeechTranscriptionResponse> {
return request<SpeechTranscriptionResponse>("/api/speech/transcribe", {
method: "POST",
body: JSON.stringify(payload),
})
},
synthesizeSpeech(payload: { text: string; format?: "mp3" | "wav" | "opus" }): Promise<SpeechSynthesisResponse> {
return request<SpeechSynthesisResponse>("/api/speech/synthesize", {
method: "POST",
body: JSON.stringify(payload),
})
},
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> { listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (path && path !== ".") { if (path && path !== ".") {

View File

@@ -1,23 +0,0 @@
import { isTauriHost } from "./runtime-env"
export async function openExternalUrl(url: string, context = "ui"): Promise<void> {
if (typeof window === "undefined") {
return
}
if (isTauriHost()) {
try {
const { openUrl } = await import("@tauri-apps/plugin-opener")
await openUrl(url)
return
} catch (error) {
console.warn(`[${context}] unable to open via system opener`, error)
}
}
try {
window.open(url, "_blank", "noopener,noreferrer")
} catch (error) {
console.warn(`[${context}] unable to open external url`, error)
}
}

View File

@@ -1,200 +0,0 @@
import { createLowlight, common } from "lowlight"
type AstNode = {
type: string
value?: string
children?: AstNode[]
startIndex?: number
endIndex?: number
lineNumber?: number
}
type SyntaxNodeEntry = {
node: AstNode
wrapper?: AstNode
}
type SyntaxFileLine = {
value: string
lineNumber: number
valueLength: number
nodeList: SyntaxNodeEntry[]
}
type LowlightApi = ReturnType<typeof createLowlight>
export function processAST(ast: { children: AstNode[] }) {
let lineNumber = 1
const syntaxObj: Record<number, SyntaxFileLine> = {}
const loopAST = (nodes: AstNode[], wrapper?: AstNode) => {
nodes.forEach((node) => {
if (node.type === "text") {
const textValue = node.value ?? ""
if (!textValue.includes("\n")) {
const valueLength = textValue.length
if (!syntaxObj[lineNumber]) {
node.startIndex = 0
node.endIndex = valueLength - 1
syntaxObj[lineNumber] = {
value: textValue,
lineNumber,
valueLength,
nodeList: [{ node, wrapper }],
}
} else {
node.startIndex = syntaxObj[lineNumber].valueLength
node.endIndex = node.startIndex + valueLength - 1
syntaxObj[lineNumber].value += textValue
syntaxObj[lineNumber].valueLength += valueLength
syntaxObj[lineNumber].nodeList.push({ node, wrapper })
}
node.lineNumber = lineNumber
return
}
const lines = textValue.split("\n")
node.children = node.children || []
for (let index = 0; index < lines.length; index++) {
const value = index === lines.length - 1 ? lines[index] : `${lines[index]}\n`
const currentLineNumber = index === 0 ? lineNumber : ++lineNumber
const valueLength = value.length
const childNode: AstNode = {
type: "text",
value,
startIndex: Infinity,
endIndex: Infinity,
lineNumber: currentLineNumber,
}
if (!syntaxObj[currentLineNumber]) {
childNode.startIndex = 0
childNode.endIndex = valueLength - 1
syntaxObj[currentLineNumber] = {
value,
lineNumber: currentLineNumber,
valueLength,
nodeList: [{ node: childNode, wrapper }],
}
} else {
childNode.startIndex = syntaxObj[currentLineNumber].valueLength
childNode.endIndex = childNode.startIndex + valueLength - 1
syntaxObj[currentLineNumber].value += value
syntaxObj[currentLineNumber].valueLength += valueLength
syntaxObj[currentLineNumber].nodeList.push({ node: childNode, wrapper })
}
node.children.push(childNode)
}
node.lineNumber = lineNumber
return
}
if (node.children) {
loopAST(node.children, node)
node.lineNumber = lineNumber
}
})
}
loopAST(ast.children)
return { syntaxFileObject: syntaxObj, syntaxFileLineNumber: lineNumber }
}
export function _getAST() {
return {}
}
const lowlight = createLowlight(common)
lowlight.register("vue", function hljsDefineVue(hljs: any) {
return {
subLanguage: "xml",
contains: [
hljs.COMMENT("<!--", "-->", { relevance: 10 }),
{
begin: /^(\s*)(<script>)/gm,
end: /^(\s*)(<\/script>)/gm,
subLanguage: "javascript",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(?:\s*)(?:<script\s+lang=(["'])ts\1>)/gm,
end: /^(\s*)(<\/script>)/gm,
subLanguage: "typescript",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(\s*)(<style(\s+scoped)?>)/gm,
end: /^(\s*)(<\/style>)/gm,
subLanguage: "css",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])(?:s[ca]ss)\1(?:\s+scoped)?>)/gm,
end: /^(\s*)(<\/style>)/gm,
subLanguage: "scss",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])stylus\1(?:\s+scoped)?>)/gm,
end: /^(\s*)(<\/style>)/gm,
subLanguage: "stylus",
excludeBegin: true,
excludeEnd: true,
},
],
}
})
let maxLineToIgnoreSyntax = 2000
const ignoreSyntaxHighlightList: (string | RegExp)[] = []
export const highlighter = {
name: "lowlight",
get maxLineToIgnoreSyntax() {
return maxLineToIgnoreSyntax
},
setMaxLineToIgnoreSyntax(value: number) {
maxLineToIgnoreSyntax = value
},
get ignoreSyntaxHighlightList() {
return ignoreSyntaxHighlightList
},
setIgnoreSyntaxHighlightList(values: (string | RegExp)[]) {
ignoreSyntaxHighlightList.length = 0
ignoreSyntaxHighlightList.push(...values)
},
getAST(raw: string, fileName?: string, lang?: string) {
const language = typeof lang === "string" ? lang.trim() : ""
if (
fileName &&
ignoreSyntaxHighlightList.some((item) => (item instanceof RegExp ? item.test(fileName) : fileName === item))
) {
return undefined
}
if (language && lowlight.registered(language)) {
return lowlight.highlight(language, raw)
}
return lowlight.highlightAuto(raw)
},
processAST(ast: { children: AstNode[] }) {
return processAST(ast)
},
hasRegisteredCurrentLang(lang: string) {
return lowlight.registered(lang)
},
getHighlighterEngine(): LowlightApi {
return lowlight
},
type: "class" as const,
}
export const versions = "local-common"

View File

@@ -34,6 +34,7 @@ export interface UseCommandsOptions {
toggleUsageMetrics: () => void toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void togglePromptSubmitOnEnter: () => void
toggleShowPromptVoiceInput: () => void
setDiffViewMode: (mode: "split" | "unified") => void setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -435,6 +436,7 @@ export function useCommands(options: UseCommandsOptions) {
toggleUsageMetrics: options.toggleUsageMetrics, toggleUsageMetrics: options.toggleUsageMetrics,
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter, togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput: options.toggleShowPromptVoiceInput,
setDiffViewMode: options.setDiffViewMode, setDiffViewMode: options.setDiffViewMode,
setToolOutputExpansion: options.setToolOutputExpansion, setToolOutputExpansion: options.setToolOutputExpansion,
setDiagnosticsExpansion: options.setDiagnosticsExpansion, setDiagnosticsExpansion: options.setDiagnosticsExpansion,

View File

@@ -114,10 +114,6 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} files changed", "instanceShell.sessionChanges.filesChanged": "{count} files changed",
"instanceShell.sessionChanges.actions.show": "Show changes", "instanceShell.sessionChanges.actions.show": "Show changes",
"instanceShell.gitChanges.loading": "Loading git changes...",
"instanceShell.gitChanges.empty": "No git changes yet.",
"instanceShell.gitChanges.deleted": "Deleted",
"instanceShell.filesShell.fileListTitle": "File list", "instanceShell.filesShell.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file", "instanceShell.filesShell.mobileSelectorLabel": "Select file",
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file", "instanceShell.filesShell.mobileSelectorEmpty": "Select a file",

View File

@@ -138,4 +138,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "Send message", "promptInput.send.ariaLabel": "Send message",
"promptInput.send.errorFallback": "Failed to send message", "promptInput.send.errorFallback": "Failed to send message",
"promptInput.send.errorTitle": "Send failed", "promptInput.send.errorTitle": "Send failed",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const } as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance", "settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications", "settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access", "settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode", "settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device", "settings.scope.device": "This device",
"settings.scope.server": "Server setting", "settings.scope.server": "Server setting",
@@ -137,6 +138,34 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.", "settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions", "settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.", "settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
"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.promptSubmit.title": "Enter to submit", "settings.behavior.promptSubmit.title": "Enter to submit",
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.", "settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const } as const

View File

@@ -90,7 +90,6 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panel de estado", "instanceShell.rightPanel.title": "Panel de estado",
"instanceShell.rightPanel.tabs.changes": "Cambios", "instanceShell.rightPanel.tabs.changes": "Cambios",
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
"instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
@@ -113,10 +112,6 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
"instanceShell.sessionChanges.actions.show": "Mostrar cambios", "instanceShell.sessionChanges.actions.show": "Mostrar cambios",
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
"instanceShell.gitChanges.deleted": "Eliminado",
"instanceShell.filesShell.fileListTitle": "Lista de archivos", "instanceShell.filesShell.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo", "instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo", "instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",

View File

@@ -140,4 +140,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "Enviar mensaje", "promptInput.send.ariaLabel": "Enviar mensaje",
"promptInput.send.errorFallback": "No se pudo enviar el mensaje", "promptInput.send.errorFallback": "No se pudo enviar el mensaje",
"promptInput.send.errorTitle": "Error al enviar", "promptInput.send.errorTitle": "Error al enviar",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const } as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance", "settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications", "settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access", "settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode", "settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device", "settings.scope.device": "This device",
"settings.scope.server": "Server setting", "settings.scope.server": "Server setting",
@@ -137,6 +138,34 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.", "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.title": "Limpieza automatica de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.", "settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
"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.promptSubmit.title": "Enter para enviar", "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 linea.",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const } as const

View File

@@ -90,7 +90,6 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panneau d'état", "instanceShell.rightPanel.title": "Panneau d'état",
"instanceShell.rightPanel.tabs.changes": "Modifications", "instanceShell.rightPanel.tabs.changes": "Modifications",
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
"instanceShell.rightPanel.tabs.files": "Fichiers", "instanceShell.rightPanel.tabs.files": "Fichiers",
"instanceShell.rightPanel.tabs.status": "Statut", "instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit", "instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
@@ -113,10 +112,6 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés", "instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
"instanceShell.sessionChanges.actions.show": "Afficher les changements", "instanceShell.sessionChanges.actions.show": "Afficher les changements",
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
"instanceShell.gitChanges.deleted": "Supprimé",
"instanceShell.filesShell.fileListTitle": "Liste des fichiers", "instanceShell.filesShell.fileListTitle": "Liste des fichiers",
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier", "instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier", "instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",

View File

@@ -140,4 +140,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "Envoyer le message", "promptInput.send.ariaLabel": "Envoyer le message",
"promptInput.send.errorFallback": "Impossible d'envoyer le message", "promptInput.send.errorFallback": "Impossible d'envoyer le message",
"promptInput.send.errorTitle": "Échec de l'envoi", "promptInput.send.errorTitle": "Échec de l'envoi",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const } as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance", "settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications", "settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access", "settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode", "settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device", "settings.scope.device": "This device",
"settings.scope.server": "Server setting", "settings.scope.server": "Server setting",
@@ -137,6 +138,34 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.", "settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides", "settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.", "settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
"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.promptSubmit.title": "Entrer pour envoyer", "settings.behavior.promptSubmit.title": "Entrer pour envoyer",
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.", "settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const } as const

View File

@@ -90,7 +90,6 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "ステータスパネル", "instanceShell.rightPanel.title": "ステータスパネル",
"instanceShell.rightPanel.tabs.changes": "変更", "instanceShell.rightPanel.tabs.changes": "変更",
"instanceShell.rightPanel.tabs.gitChanges": "Git 変更",
"instanceShell.rightPanel.tabs.files": "ファイル", "instanceShell.rightPanel.tabs.files": "ファイル",
"instanceShell.rightPanel.tabs.status": "ステータス", "instanceShell.rightPanel.tabs.status": "ステータス",
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ", "instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
@@ -113,10 +112,6 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました", "instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
"instanceShell.sessionChanges.actions.show": "変更を表示", "instanceShell.sessionChanges.actions.show": "変更を表示",
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
"instanceShell.gitChanges.deleted": "削除済み",
"instanceShell.filesShell.fileListTitle": "ファイル一覧", "instanceShell.filesShell.fileListTitle": "ファイル一覧",
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択", "instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください", "instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",

View File

@@ -140,4 +140,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "メッセージを送信", "promptInput.send.ariaLabel": "メッセージを送信",
"promptInput.send.errorFallback": "メッセージの送信に失敗しました", "promptInput.send.errorFallback": "メッセージの送信に失敗しました",
"promptInput.send.errorTitle": "送信に失敗", "promptInput.send.errorTitle": "送信に失敗",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const } as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance", "settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications", "settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access", "settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode", "settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device", "settings.scope.device": "This device",
"settings.scope.server": "Server setting", "settings.scope.server": "Server setting",
@@ -137,6 +138,34 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。", "settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ", "settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。", "settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
"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.promptSubmit.title": "Enterで送信", "settings.behavior.promptSubmit.title": "Enterで送信",
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。", "settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const } as const

View File

@@ -90,7 +90,6 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Панель состояния", "instanceShell.rightPanel.title": "Панель состояния",
"instanceShell.rightPanel.tabs.changes": "Изменения", "instanceShell.rightPanel.tabs.changes": "Изменения",
"instanceShell.rightPanel.tabs.gitChanges": "Изменения Git",
"instanceShell.rightPanel.tabs.files": "Файлы", "instanceShell.rightPanel.tabs.files": "Файлы",
"instanceShell.rightPanel.tabs.status": "Статус", "instanceShell.rightPanel.tabs.status": "Статус",
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели", "instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
@@ -113,10 +112,6 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}", "instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
"instanceShell.sessionChanges.actions.show": "Показать изменения", "instanceShell.sessionChanges.actions.show": "Показать изменения",
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
"instanceShell.gitChanges.deleted": "Удалено",
"instanceShell.filesShell.fileListTitle": "Список файлов", "instanceShell.filesShell.fileListTitle": "Список файлов",
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл", "instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл", "instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",

View File

@@ -140,4 +140,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "Отправить сообщение", "promptInput.send.ariaLabel": "Отправить сообщение",
"promptInput.send.errorFallback": "Не удалось отправить сообщение", "promptInput.send.errorFallback": "Не удалось отправить сообщение",
"promptInput.send.errorTitle": "Не удалось отправить", "promptInput.send.errorTitle": "Не удалось отправить",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const } as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance", "settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications", "settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access", "settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode", "settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device", "settings.scope.device": "This device",
"settings.scope.server": "Server setting", "settings.scope.server": "Server setting",
@@ -137,6 +138,34 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.", "settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий", "settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.", "settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
"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.promptSubmit.title": "Enter для отправки", "settings.behavior.promptSubmit.title": "Enter для отправки",
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.", "settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const } as const

View File

@@ -90,7 +90,6 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "状态面板", "instanceShell.rightPanel.title": "状态面板",
"instanceShell.rightPanel.tabs.changes": "更改", "instanceShell.rightPanel.tabs.changes": "更改",
"instanceShell.rightPanel.tabs.gitChanges": "Git 更改",
"instanceShell.rightPanel.tabs.files": "文件", "instanceShell.rightPanel.tabs.files": "文件",
"instanceShell.rightPanel.tabs.status": "状态", "instanceShell.rightPanel.tabs.status": "状态",
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页", "instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
@@ -113,10 +112,6 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件", "instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件",
"instanceShell.sessionChanges.actions.show": "显示更改", "instanceShell.sessionChanges.actions.show": "显示更改",
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
"instanceShell.gitChanges.deleted": "已删除",
"instanceShell.filesShell.fileListTitle": "文件列表", "instanceShell.filesShell.fileListTitle": "文件列表",
"instanceShell.filesShell.mobileSelectorLabel": "选择文件", "instanceShell.filesShell.mobileSelectorLabel": "选择文件",
"instanceShell.filesShell.mobileSelectorEmpty": "请选择文件", "instanceShell.filesShell.mobileSelectorEmpty": "请选择文件",

View File

@@ -140,4 +140,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "发送消息", "promptInput.send.ariaLabel": "发送消息",
"promptInput.send.errorFallback": "发送消息失败", "promptInput.send.errorFallback": "发送消息失败",
"promptInput.send.errorTitle": "发送失败", "promptInput.send.errorTitle": "发送失败",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const } as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance", "settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications", "settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access", "settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode", "settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device", "settings.scope.device": "This device",
"settings.scope.server": "Server setting", "settings.scope.server": "Server setting",
@@ -137,6 +138,34 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。", "settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。",
"settings.behavior.autoCleanup.title": "自动清理空会话", "settings.behavior.autoCleanup.title": "自动清理空会话",
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。", "settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
"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.promptSubmit.title": "回车发送", "settings.behavior.promptSubmit.title": "回车发送",
"settings.behavior.promptSubmit.subtitle": "使用回车发送Cmd/Ctrl+回车插入新行。", "settings.behavior.promptSubmit.subtitle": "使用回车发送Cmd/Ctrl+回车插入新行。",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const } as const

View File

@@ -1,8 +1,7 @@
import { marked } from "marked" import { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
import { getLogger } from "./logger" import { getLogger } from "./logger"
import { tGlobal } from "./i18n" import { tGlobal } from "./i18n"
import type { Highlighter } from "shiki/bundle/full"
import { decodeHtmlEntities, escapeHtml } from "./text-render-utils"
const log = getLogger("actions") const log = getLogger("actions")
@@ -12,8 +11,43 @@ let currentTheme: "light" | "dark" = "light"
let isInitialized = false let isInitialized = false
let highlightSuppressed = false let highlightSuppressed = false
let rendererSetup = false let rendererSetup = false
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null const extensionToLanguage: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
sh: "bash",
bash: "bash",
json: "json",
html: "html",
css: "css",
md: "markdown",
yaml: "yaml",
yml: "yaml",
sql: "sql",
rs: "rust",
go: "go",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
h: "cpp",
c: "c",
java: "java",
cs: "csharp",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
}
export function getLanguageFromPath(path?: string | null): string | undefined {
if (!path) return undefined
const ext = path.split(".").pop()?.toLowerCase()
return ext ? extensionToLanguage[ext] : undefined
}
// Track loaded languages and queue for on-demand loading // Track loaded languages and queue for on-demand loading
const loadedLanguages = new Set<string>() const loadedLanguages = new Set<string>()
@@ -55,15 +89,10 @@ async function getOrCreateHighlighter() {
return highlighterPromise return highlighterPromise
} }
highlighterPromise = (async () => { // Create highlighter with no preloaded languages
const shiki = await loadShikiModule() highlighterPromise = createHighlighter({
return shiki.createHighlighter({ themes: ["github-light", "github-light-high-contrast", "github-dark"],
themes: ["github-light", "github-light-high-contrast", "github-dark"], langs: [],
langs: [],
})
})().catch((error) => {
highlighterPromise = null
throw error
}) })
highlighter = await highlighterPromise highlighter = await highlighterPromise
@@ -71,37 +100,12 @@ async function getOrCreateHighlighter() {
return highlighter return highlighter
} }
async function loadShikiModule() {
if (!shikiModulePromise) {
shikiModulePromise = import("shiki/bundle/full").then((module) => {
bundledLanguagesCache = module.bundledLanguages
return module
})
}
return shikiModulePromise
}
function queueHighlighterWarmup() {
if (highlighter || highlighterPromise) {
return
}
void getOrCreateHighlighter().catch((error) => {
log.warn("Failed to initialize markdown highlighter", error)
})
}
function normalizeLanguageToken(token: string): string { function normalizeLanguageToken(token: string): string {
return token.trim().toLowerCase() return token.trim().toLowerCase()
} }
function resolveLanguage(token: string): { canonical: string | null; raw: string } { function resolveLanguage(token: string): { canonical: string | null; raw: string } {
const normalized = normalizeLanguageToken(token) const normalized = normalizeLanguageToken(token)
const bundledLanguages = bundledLanguagesCache
if (!bundledLanguages) {
return { canonical: null, raw: normalized }
}
// Check if it's a direct key match // Check if it's a direct key match
if (normalized in bundledLanguages) { if (normalized in bundledLanguages) {
@@ -144,43 +148,32 @@ async function ensureLanguages(content: string) {
// Queue language loading tasks // Queue language loading tasks
for (const token of foundLanguages) { for (const token of foundLanguages) {
const rawToken = normalizeLanguageToken(token) const { canonical, raw } = resolveLanguage(token)
if (!rawToken) { const langKey = canonical || raw
continue
}
// Skip "text" and aliases since Shiki handles plain text already // Skip "text" and aliases since Shiki handles plain text already
if (rawToken === "text") { if (langKey === "text" || raw === "text") {
continue continue
} }
// Skip if already loaded or queued // Skip if already loaded or queued
if (loadedLanguages.has(rawToken) || queuedLanguages.has(rawToken)) { if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) {
continue continue
} }
queuedLanguages.add(rawToken) queuedLanguages.add(langKey)
// Queue the language loading task // Queue the language loading task
languageLoadQueue.push(async () => { languageLoadQueue.push(async () => {
try { try {
await loadShikiModule()
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
if (langKey === "text" || raw === "text") {
return
}
const h = await getOrCreateHighlighter() const h = await getOrCreateHighlighter()
await h.loadLanguage(langKey as never) await h.loadLanguage(langKey as never)
loadedLanguages.add(langKey) loadedLanguages.add(langKey)
loadedLanguages.add(raw)
triggerLanguageListeners() triggerLanguageListeners()
} catch { } catch {
// Quietly ignore errors // Quietly ignore errors
} finally { } finally {
queuedLanguages.delete(rawToken) queuedLanguages.delete(langKey)
} }
}) })
} }
@@ -191,6 +184,52 @@ async function ensureLanguages(content: string) {
} }
} }
export function decodeHtmlEntities(content: string): string {
if (!content.includes("&")) {
return content
}
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
const namedEntities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
}
let result = content
let previous = ""
while (result.includes("&") && result !== previous) {
previous = result
result = result.replace(entityPattern, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === "#") {
const isHex = entity[1]?.toLowerCase() === "x"
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
if (!Number.isNaN(value)) {
try {
return String.fromCodePoint(value)
} catch {
return match
}
}
return match
}
const decoded = namedEntities[entity.toLowerCase()]
return decoded !== undefined ? decoded : match
})
}
return result
}
async function runLanguageLoadQueue() { async function runLanguageLoadQueue() {
if (isQueueRunning || languageLoadQueue.length === 0) { if (isQueueRunning || languageLoadQueue.length === 0) {
return return
@@ -210,6 +249,7 @@ async function runLanguageLoadQueue() {
function setupRenderer(isDark: boolean) { function setupRenderer(isDark: boolean) {
currentTheme = isDark ? "dark" : "light" currentTheme = isDark ? "dark" : "light"
if (!highlighter) return
if (rendererSetup) return if (rendererSetup) return
marked.setOptions({ marked.setOptions({
@@ -290,9 +330,8 @@ function setupRenderer(isDark: boolean) {
} }
export async function initMarkdown(isDark: boolean) { export async function initMarkdown(isDark: boolean) {
setupRenderer(isDark)
queueHighlighterWarmup()
await getOrCreateHighlighter() await getOrCreateHighlighter()
setupRenderer(isDark)
isInitialized = true isInitialized = true
} }
@@ -311,16 +350,15 @@ export async function renderMarkdown(
}, },
): Promise<string> { ): Promise<string> {
if (!isInitialized) { if (!isInitialized) {
setupRenderer(currentTheme === "dark") await initMarkdown(currentTheme === "dark")
isInitialized = true
} }
const suppressHighlight = options?.suppressHighlight ?? false const suppressHighlight = options?.suppressHighlight ?? false
const decoded = decodeHtmlEntities(content) const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) { if (!suppressHighlight) {
queueHighlighterWarmup() // Queue language loading but don't wait for it to complete
void ensureLanguages(decoded) await ensureLanguages(decoded)
} }
const previousSuppressed = highlightSuppressed const previousSuppressed = highlightSuppressed
@@ -337,3 +375,13 @@ export async function renderMarkdown(
export async function getSharedHighlighter(): Promise<Highlighter> { export async function getSharedHighlighter(): Promise<Highlighter> {
return getOrCreateHighlighter() return getOrCreateHighlighter()
} }
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<"']/g, (m) => map[m])
}

View File

@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env" import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger" import { getLogger } from "../logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -16,8 +15,9 @@ export async function restartCli(): Promise<boolean> {
} }
if (runtimeEnv.host === "tauri") { if (runtimeEnv.host === "tauri") {
if (typeof window.__TAURI__?.core?.invoke === "function") { const tauri = (window as typeof window & { __TAURI__?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> } }).__TAURI__
await invoke("cli_restart") if (tauri?.invoke) {
await tauri.invoke("cli_restart")
return true return true
} }
return false return false

View File

@@ -1,4 +1,3 @@
import { listen } from "@tauri-apps/api/event"
import { getLogger } from "../logger" import { getLogger } from "../logger"
import { runtimeEnv } from "../runtime-env" import { runtimeEnv } from "../runtime-env"
@@ -108,8 +107,13 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
return () => {} return () => {}
} }
const eventApi = window.__TAURI__?.event
if (!eventApi?.listen) {
return () => {}
}
try { try {
const unlisten = await listen("desktop:folder-drop", (event) => { const unlisten = await eventApi.listen("desktop:folder-drop", (event) => {
const payload = (event.payload ?? {}) as TauriFolderDropPayload const payload = (event.payload ?? {}) as TauriFolderDropPayload
const paths = normalizePathList(payload.paths) const paths = normalizePathList(payload.paths)
if (paths.length > 0) { if (paths.length > 0) {
@@ -130,10 +134,15 @@ export async function listenForNativeFolderDropState(onState: (state: NativeFold
return () => {} return () => {}
} }
const eventApi = window.__TAURI__?.event
if (!eventApi?.listen) {
return () => {}
}
try { try {
const [unlistenEnter, unlistenLeave] = await Promise.all([ const [unlistenEnter, unlistenLeave] = await Promise.all([
listen("desktop:folder-drag-enter", () => onState("enter")), eventApi.listen("desktop:folder-drag-enter", () => onState("enter")),
listen("desktop:folder-drag-leave", () => onState("leave")), eventApi.listen("desktop:folder-drag-leave", () => onState("leave")),
]) ])
return () => { return () => {
unlistenEnter() unlistenEnter()

View File

@@ -1,21 +1,43 @@
import { open } from "@tauri-apps/plugin-dialog"
import type { NativeDialogOptions } from "../native-functions" import type { NativeDialogOptions } from "../native-functions"
import { getLogger } from "../../logger" import { getLogger } from "../../logger"
const log = getLogger("actions") const log = getLogger("actions")
interface TauriDialogModule {
open?: (
options: {
title?: string
defaultPath?: string
filters?: { name?: string; extensions: string[] }[]
directory?: boolean
multiple?: boolean
},
) => Promise<string | string[] | null>
}
interface TauriBridge {
dialog?: TauriDialogModule
}
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> { export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return null return null
} }
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
const dialogApi = tauriBridge?.dialog
if (!dialogApi?.open) {
return null
}
try { try {
const response = await open({ const response = await dialogApi.open({
title: options.title, title: options.title,
defaultPath: options.defaultPath, defaultPath: options.defaultPath,
directory: options.mode === "directory", directory: options.mode === "directory",
multiple: false, multiple: false,
filters: options.filters?.map((filter) => ({ filters: options.filters?.map((filter) => ({
name: filter.name ?? "Files", name: filter.name,
extensions: filter.extensions, extensions: filter.extensions,
})), })),
}) })

View File

@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env" import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger" import { getLogger } from "../logger"
@@ -61,7 +60,8 @@ function hasAnyWakeLockSupport(): boolean {
if (api?.setWakeLock) return true if (api?.setWakeLock) return true
} }
if (runtimeEnv.host === "tauri") { if (runtimeEnv.host === "tauri") {
return typeof window.__TAURI__?.core?.invoke === "function" // We'll attempt dynamic import; treat as potentially supported.
return true
} }
return Boolean((navigator as any)?.wakeLock?.request) return Boolean((navigator as any)?.wakeLock?.request)
} }
@@ -84,18 +84,21 @@ async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
async function setTauriWakeLock(enabled: boolean): Promise<boolean> { async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
try { try {
if (!hasAnyWakeLockSupport()) { const mod = await import("tauri-plugin-keepawake-api")
const start = (mod as any).start as ((config?: any) => Promise<void>) | undefined
const stop = (mod as any).stop as (() => Promise<void>) | undefined
if (!start || !stop) {
return false return false
} }
if (enabled) { if (enabled) {
// Match Electron's prevent-display-sleep behavior by keeping the display // Plugin config supports toggling display/idle/sleep. Use a conservative
// awake without blocking explicit system sleep requests. // default to keep both system + display awake.
await invoke("wake_lock_start", { config: { display: true, idle: false, sleep: false } }) await start({ display: true, idle: true, sleep: true })
return true return true
} }
await invoke("wake_lock_stop") await stop()
return false return false
} catch (error) { } catch (error) {
log.log("[wake-lock] tauri wake lock failed", error) log.log("[wake-lock] tauri wake lock failed", error)
@@ -134,12 +137,13 @@ export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
inFlight = (async () => { inFlight = (async () => {
try { try {
const ok = await applyWakeLock(target) const ok = await applyWakeLock(target)
applied = target ? ok : false // Treat disable attempts as applied even if the underlying API doesn't exist.
applied = target
return ok return ok
} finally { } finally {
inFlight = null inFlight = null
// If desired changed while in-flight, re-apply once. // If desired changed while in-flight, re-apply once.
if (desired !== target) { if (desired !== applied) {
void setWakeLockDesired(desired) void setWakeLockDesired(desired)
} }

View File

@@ -9,14 +9,17 @@ export interface RuntimeEnvironment {
} }
declare global { declare global {
interface TauriCoreModule {
invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
}
interface Window { interface Window {
electronAPI?: unknown electronAPI?: unknown
__TAURI__?: { __TAURI__?: {
core?: TauriCoreModule invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
dialog?: {
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
save?: (options: Record<string, unknown>) => Promise<string | null>
}
} }
} }
} }

View File

@@ -42,6 +42,7 @@ export type BehaviorRegistryActions = {
toggleUsageMetrics: () => void toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void togglePromptSubmitOnEnter: () => void
toggleShowPromptVoiceInput: () => void
setDiffViewMode: (mode: "split" | "unified") => void setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -248,6 +249,24 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
) )
}, },
}, },
{
kind: "toggle",
id: "behavior.promptVoiceInput",
titleKey: "settings.behavior.promptVoiceInput.title",
subtitleKey: "settings.behavior.promptVoiceInput.subtitle",
get: (p) => Boolean(p.showPromptVoiceInput ?? true),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showPromptVoiceInput: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showPromptVoiceInput ?? true),
actions.toggleShowPromptVoiceInput,
next,
)
},
},
{ {
kind: "toggle", kind: "toggle",
id: "behavior.promptSubmitOnEnter", id: "behavior.promptSubmitOnEnter",

View File

@@ -1,92 +0,0 @@
const extensionToLanguage: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
sh: "bash",
bash: "bash",
json: "json",
html: "html",
css: "css",
md: "markdown",
yaml: "yaml",
yml: "yaml",
sql: "sql",
rs: "rust",
go: "go",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
h: "cpp",
c: "c",
java: "java",
cs: "csharp",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
}
export function getLanguageFromPath(path?: string | null): string | undefined {
if (!path) return undefined
const ext = path.split(".").pop()?.toLowerCase()
return ext ? extensionToLanguage[ext] : undefined
}
export function decodeHtmlEntities(content: string): string {
if (!content.includes("&")) {
return content
}
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
const namedEntities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
}
let result = content
let previous = ""
while (result.includes("&") && result !== previous) {
previous = result
result = result.replace(entityPattern, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === "#") {
const isHex = entity[1]?.toLowerCase() === "x"
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
if (!Number.isNaN(value)) {
try {
return String.fromCodePoint(value)
} catch {
return match
}
}
return match
}
const decoded = namedEntities[entity.toLowerCase()]
return decoded !== undefined ? decoded : match
})
}
return result
}
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<"']/g, (match) => map[match])
}

View File

@@ -1,5 +1,3 @@
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Show, createSignal, onCleanup, onMount } from "solid-js" import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import iconUrl from "../../images/CodeNomad-Icon.png" import iconUrl from "../../images/CodeNomad-Icon.png"
@@ -29,6 +27,13 @@ interface CliStatus {
error?: string | null error?: string | null
} }
interface TauriBridge {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
}
function pickPhraseKey(previous?: PhraseKey) { function pickPhraseKey(previous?: PhraseKey) {
const filtered = phraseKeys.filter((key) => key !== previous) const filtered = phraseKeys.filter((key) => key !== previous)
const source = filtered.length > 0 ? filtered : phraseKeys const source = filtered.length > 0 ? filtered : phraseKeys
@@ -41,6 +46,17 @@ function navigateTo(url?: string | null) {
window.location.replace(url) window.location.replace(url)
} }
function getTauriBridge(): TauriBridge | null {
if (typeof window === "undefined") {
return null
}
const bridge = (window as { __TAURI__?: TauriBridge }).__TAURI__
if (!bridge || !bridge.event || !bridge.invoke) {
return null
}
return bridge
}
function annotateDocument() { function annotateDocument() {
if (typeof document === "undefined") { if (typeof document === "undefined") {
return return
@@ -61,22 +77,25 @@ function LoadingApp() {
setPhraseKey(pickPhraseKey()) setPhraseKey(pickPhraseKey())
const unsubscribers: Array<() => void> = [] const unsubscribers: Array<() => void> = []
async function bootstrapTauri() { async function bootstrapTauri(tauriBridge: TauriBridge | null) {
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
return
}
try { try {
const readyUnlisten = await listen("cli:ready", (event) => { const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
const payload = (event?.payload as CliStatus) || {} const payload = (event?.payload as CliStatus) || {}
setError(null) setError(null)
setStatusKey(null) setStatusKey(null)
navigateTo(payload.url) navigateTo(payload.url)
}) })
const errorUnlisten = await listen("cli:error", (event) => { const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
const payload = (event?.payload as CliStatus) || {} const payload = (event?.payload as CliStatus) || {}
if (payload.error) { if (payload.error) {
setError(payload.error) setError(payload.error)
setStatusKey("loadingScreen.status.issue") setStatusKey("loadingScreen.status.issue")
} }
}) })
const statusUnlisten = await listen("cli:status", (event) => { const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
const payload = (event?.payload as CliStatus) || {} const payload = (event?.payload as CliStatus) || {}
if (payload.state === "error" && payload.error) { if (payload.state === "error" && payload.error) {
setError(payload.error) setError(payload.error)
@@ -90,7 +109,7 @@ function LoadingApp() {
}) })
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten) unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
const result = await invoke<CliStatus>("cli_get_status") const result = await tauriBridge.invoke<CliStatus>("cli_get_status")
if (result?.state === "ready" && result.url) { if (result?.state === "ready" && result.url) {
navigateTo(result.url) navigateTo(result.url)
} else if (result?.state === "error" && result.error) { } else if (result?.state === "error" && result.error) {
@@ -104,7 +123,7 @@ function LoadingApp() {
} }
if (isTauriHost()) { if (isTauriHost()) {
void bootstrapTauri() void bootstrapTauri(getTauriBridge())
} }
onCleanup(() => { onCleanup(() => {

View File

@@ -5,7 +5,7 @@ import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
import type { Message, MessageInfo, ClientPart } from "../../types/message" import type { Message, MessageInfo, ClientPart } from "../../types/message"
import type { Session } from "../../types/session" import type { Session } from "../../types/session"
import { messageStoreBus } from "./bus" import { messageStoreBus } from "./bus"
import type { MessageStatus, ReplaceMessageIdOptions, SessionRevertState } from "./types" import type { MessageStatus, SessionRevertState } from "./types"
interface SessionMetadata { interface SessionMetadata {
id: string id: string
@@ -121,10 +121,10 @@ export function applyPartDeltaV2(
}) })
} }
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string, options?: Omit<ReplaceMessageIdOptions, "oldId" | "newId">): void { export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
if (!oldId || !newId || oldId === newId) return if (!oldId || !newId || oldId === newId) return
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
store.replaceMessageId({ oldId, newId, ...(options ?? {}) }) store.replaceMessageId({ oldId, newId })
} }
function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined { function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined {

View File

@@ -586,10 +586,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() }) bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
return return
} }
const partId = ensurePartId(input.messageId, input.part, message.partIds.length) const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
const cloned = clonePart(input.part) const cloned = clonePart(input.part)
setState( setState(
"messages", "messages",
input.messageId, input.messageId,
@@ -792,8 +792,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
id: options.newId, id: options.newId,
isEphemeral: false, isEphemeral: false,
updatedAt: Date.now(), updatedAt: Date.now(),
partIds: options.clearParts ? [] : existing.partIds,
parts: options.clearParts ? {} : existing.parts,
} }
setState("messages", options.newId, cloned) setState("messages", options.newId, cloned)

View File

@@ -1,4 +1,4 @@
import { decodeHtmlEntities } from "../../lib/text-render-utils" import { decodeHtmlEntities } from "../../lib/markdown"
function decodeTextSegment(segment: any): any { function decodeTextSegment(segment: any): any {
if (typeof segment === "string") { if (typeof segment === "string") {

View File

@@ -152,7 +152,6 @@ export interface PartUpdateInput {
export interface ReplaceMessageIdOptions { export interface ReplaceMessageIdOptions {
oldId: string oldId: string
newId: string newId: string
clearParts?: boolean
} }
export interface ScrollCacheKey { export interface ScrollCacheKey {

View File

@@ -7,6 +7,7 @@ import {
updateInstanceConfig as updateInstanceData, updateInstanceConfig as updateInstanceData,
} from "./instance-config" } from "./instance-config"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech"
const log = getLogger("actions") const log = getLogger("actions")
@@ -27,6 +28,16 @@ export type DiffViewMode = "split" | "unified"
export type ExpansionPreference = "expanded" | "collapsed" export type ExpansionPreference = "expanded" | "collapsed"
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded" export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
export type ListeningMode = "local" | "all" export type ListeningMode = "local" | "all"
export type SpeechProviderPreference = "openai-compatible"
export interface SpeechSettings {
provider: SpeechProviderPreference
apiKey?: string
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
}
export interface UiSettings { export interface UiSettings {
showThinkingBlocks: boolean showThinkingBlocks: boolean
@@ -34,6 +45,7 @@ export interface UiSettings {
thinkingBlocksExpansion: ExpansionPreference thinkingBlocksExpansion: ExpansionPreference
showTimelineTools: boolean showTimelineTools: boolean
promptSubmitOnEnter: boolean promptSubmitOnEnter: boolean
showPromptVoiceInput: boolean
locale?: string locale?: string
diffViewMode: DiffViewMode diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference toolOutputExpansion: ExpansionPreference
@@ -75,6 +87,7 @@ interface ServerConfigBucket {
listeningMode?: ListeningMode listeningMode?: ListeningMode
environmentVariables?: Record<string, string> environmentVariables?: Record<string, string>
opencodeBinary?: string opencodeBinary?: string
speech?: Partial<SpeechSettings>
} }
interface UiStateBucket { interface UiStateBucket {
@@ -107,6 +120,7 @@ const defaultUiSettings: UiSettings = {
thinkingBlocksExpansion: "expanded", thinkingBlocksExpansion: "expanded",
showTimelineTools: true, showTimelineTools: true,
promptSubmitOnEnter: false, promptSubmitOnEnter: false,
showPromptVoiceInput: true,
diffViewMode: "split", diffViewMode: "split",
toolOutputExpansion: "expanded", toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded", diagnosticsExpansion: "expanded",
@@ -120,6 +134,13 @@ const defaultUiSettings: UiSettings = {
notifyOnIdle: true, notifyOnIdle: true,
} }
const defaultSpeechSettings: SpeechSettings = {
provider: "openai-compatible",
sttModel: "gpt-4o-mini-transcribe",
ttsModel: "gpt-4o-mini-tts",
ttsVoice: "alloy",
}
function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings { function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
const sanitized = input ?? {} const sanitized = input ?? {}
return { return {
@@ -129,6 +150,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion, thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools, showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter, promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
locale: sanitized.locale ?? defaultUiSettings.locale, locale: sanitized.locale ?? defaultUiSettings.locale,
diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode, diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion, toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion,
@@ -156,6 +178,27 @@ function normalizeRecord(value: unknown): Record<string, string> {
return out return out
} }
function normalizeSpeechSettings(input?: Partial<SpeechSettings> | null): SpeechSettings {
const sanitized = input ?? {}
return {
provider: sanitized.provider === "openai-compatible" ? sanitized.provider : defaultSpeechSettings.provider,
apiKey: typeof sanitized.apiKey === "string" && sanitized.apiKey.trim() ? sanitized.apiKey.trim() : undefined,
baseUrl: typeof sanitized.baseUrl === "string" && sanitized.baseUrl.trim() ? sanitized.baseUrl.trim() : undefined,
sttModel:
typeof sanitized.sttModel === "string" && sanitized.sttModel.trim()
? sanitized.sttModel.trim()
: defaultSpeechSettings.sttModel,
ttsModel:
typeof sanitized.ttsModel === "string" && sanitized.ttsModel.trim()
? sanitized.ttsModel.trim()
: defaultSpeechSettings.ttsModel,
ttsVoice:
typeof sanitized.ttsVoice === "string" && sanitized.ttsVoice.trim()
? sanitized.ttsVoice.trim()
: defaultSpeechSettings.ttsVoice,
}
}
function cloneArray<T>(value: unknown, mapper: (item: any) => T | null): T[] { function cloneArray<T>(value: unknown, mapper: (item: any) => T | null): T[] {
if (!Array.isArray(value)) return [] if (!Array.isArray(value)) return []
const out: T[] = [] const out: T[] = []
@@ -206,12 +249,15 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
} }
} }
function normalizeServerConfig(input?: ServerConfigBucket | null): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> { function normalizeServerConfig(
input?: ServerConfigBucket | null,
): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> & { speech: SpeechSettings } {
const source = input ?? {} const source = input ?? {}
const listeningMode = source.listeningMode === "all" ? "all" : "local" const listeningMode = source.listeningMode === "all" ? "all" : "local"
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode" const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
const environmentVariables = normalizeRecord(source.environmentVariables) const environmentVariables = normalizeRecord(source.environmentVariables)
return { listeningMode, opencodeBinary, environmentVariables } const speech = normalizeSpeechSettings(source.speech)
return { listeningMode, opencodeBinary, environmentVariables, speech }
} }
function getModelKey(model: { providerId: string; modelId: string }): string { function getModelKey(model: { providerId: string; modelId: string }): string {
@@ -342,6 +388,16 @@ function updateLastUsedBinary(path: string): void {
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error)) void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
} }
async function updateSpeechSettings(updates: Partial<SpeechSettings>): Promise<void> {
const next = normalizeSpeechSettings({ ...serverSettings().speech, ...updates })
try {
await patchConfigOwner("server", { speech: next })
} catch (error) {
log.error("Failed to update speech settings", error)
throw error
}
}
function addOpenCodeBinary(path: string, version?: string): void { function addOpenCodeBinary(path: string, version?: string): void {
const nextList = buildBinaryList(path, version, opencodeBinaries()) const nextList = buildBinaryList(path, version, opencodeBinaries())
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error)) void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error))
@@ -476,6 +532,10 @@ function togglePromptSubmitOnEnter(): void {
updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter }) updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter })
} }
function toggleShowPromptVoiceInput(): void {
updateUiSettings({ showPromptVoiceInput: !preferences().showPromptVoiceInput })
}
function toggleAutoCleanupBlankSessions(): void { function toggleAutoCleanupBlankSessions(): void {
const nextValue = !preferences().autoCleanupBlankSessions const nextValue = !preferences().autoCleanupBlankSessions
log.info("toggle auto cleanup", { value: nextValue }) log.info("toggle auto cleanup", { value: nextValue })
@@ -521,6 +581,7 @@ interface ConfigContextValue {
addEnvironmentVariable: typeof addEnvironmentVariable addEnvironmentVariable: typeof addEnvironmentVariable
removeEnvironmentVariable: typeof removeEnvironmentVariable removeEnvironmentVariable: typeof removeEnvironmentVariable
updateLastUsedBinary: typeof updateLastUsedBinary updateLastUsedBinary: typeof updateLastUsedBinary
updateSpeechSettings: typeof updateSpeechSettings
// ui-owned state // ui-owned state
recentFolders: typeof recentFolders recentFolders: typeof recentFolders
@@ -544,6 +605,7 @@ interface ConfigContextValue {
toggleUsageMetrics: typeof toggleUsageMetrics toggleUsageMetrics: typeof toggleUsageMetrics
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter
toggleShowPromptVoiceInput: typeof toggleShowPromptVoiceInput
setDiffViewMode: typeof setDiffViewMode setDiffViewMode: typeof setDiffViewMode
setToolOutputExpansion: typeof setToolOutputExpansion setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion setDiagnosticsExpansion: typeof setDiagnosticsExpansion
@@ -569,6 +631,7 @@ const configContextValue: ConfigContextValue = {
addEnvironmentVariable, addEnvironmentVariable,
removeEnvironmentVariable, removeEnvironmentVariable,
updateLastUsedBinary, updateLastUsedBinary,
updateSpeechSettings,
recentFolders, recentFolders,
opencodeBinaries, opencodeBinaries,
uiState, uiState,
@@ -588,6 +651,7 @@ const configContextValue: ConfigContextValue = {
toggleUsageMetrics, toggleUsageMetrics,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
@@ -610,6 +674,8 @@ export const ConfigProvider: ParentComponent = (props) => {
const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => { const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => {
setServerConfigBucket(bucket as any) setServerConfigBucket(bucket as any)
setIsLoaded(true) setIsLoaded(true)
resetSpeechCapabilities()
void loadSpeechCapabilities(true)
}) })
const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => { const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => {
setUiStateBucket(bucket as any) setUiStateBucket(bucket as any)
@@ -648,6 +714,7 @@ export {
addEnvironmentVariable, addEnvironmentVariable,
removeEnvironmentVariable, removeEnvironmentVariable,
updateLastUsedBinary, updateLastUsedBinary,
updateSpeechSettings,
addRecentFolder, addRecentFolder,
removeRecentFolder, removeRecentFolder,
addOpenCodeBinary, addOpenCodeBinary,
@@ -664,6 +731,7 @@ export {
toggleUsageMetrics, toggleUsageMetrics,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,

View File

@@ -94,7 +94,7 @@ async function sendMessage(
} }
const messageId = createId("msg") const messageId = createId("msg")
const textPartId = createId("prt") const textPartId = createId("part")
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments) const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
@@ -110,6 +110,7 @@ async function sendMessage(
const requestParts: any[] = [ const requestParts: any[] = [
{ {
id: textPartId,
type: "text" as const, type: "text" as const,
text: resolvedPrompt, text: resolvedPrompt,
}, },
@@ -119,8 +120,9 @@ async function sendMessage(
for (const att of attachments) { for (const att of attachments) {
const source = att.source const source = att.source
if (source.type === "file") { if (source.type === "file") {
const partId = createId("prt") const partId = createId("part")
requestParts.push({ requestParts.push({
id: partId,
type: "file" as const, type: "file" as const,
url: att.url, url: att.url,
mime: source.mime, mime: source.mime,
@@ -146,8 +148,9 @@ async function sendMessage(
continue continue
} }
const partId = createId("prt") const partId = createId("part")
requestParts.push({ requestParts.push({
id: partId,
type: "text" as const, type: "text" as const,
text: value, text: value,
}) })
@@ -181,6 +184,7 @@ async function sendMessage(
}) })
const requestBody = { const requestBody = {
messageID: messageId,
parts: requestParts, parts: requestParts,
...(session.agent && { agent: session.agent }), ...(session.agent && { agent: session.agent }),
...(session.model.providerId && ...(session.model.providerId &&

View File

@@ -77,29 +77,6 @@ function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
return false return false
} }
function isChildSession(instanceId: string, sessionId: string): boolean | null {
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) return null
return session.parentId !== null && session.parentId !== undefined
}
function shouldSendOsNotificationForSession(
kind: "needsInput" | "idle",
instanceId: string,
sessionId: string | undefined | null,
): boolean {
if (!shouldSendOsNotification(kind)) return false
if (!sessionId) return true
const child = isChildSession(instanceId, sessionId)
// Avoid notification spam from spawned child/subagent sessions arriving before hydration.
if (child === null) return false
if (child) return false
return true
}
function getInstanceDisplayName(instanceId: string): string { function getInstanceDisplayName(instanceId: string): string {
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
@@ -263,22 +240,19 @@ function resolveMessageRole(info?: MessageInfo | null): MessageRole {
return info?.role === "user" ? "user" : "assistant" return info?.role === "user" ? "user" : "assistant"
} }
function findPendingSyntheticMessageId( function findPendingMessageId(
store: InstanceMessageStore, store: InstanceMessageStore,
sessionId: string, sessionId: string,
role: MessageRole, role: MessageRole,
): string | undefined { ): string | undefined {
const messageIds = store.getSessionMessageIds(sessionId) const messageIds = store.getSessionMessageIds(sessionId)
for (const messageId of messageIds) { const lastId = messageIds[messageIds.length - 1]
const record = store.getMessage(messageId) if (!lastId) return undefined
if (!record) continue const record = store.getMessage(lastId)
if (record.sessionId !== sessionId) continue if (!record) return undefined
if (record.role !== role) continue if (record.sessionId !== sessionId) return undefined
if (record.status !== "sending") continue if (record.role !== role) return undefined
if (!record.isEphemeral) continue return record.status === "sending" ? record.id : undefined
return record.id
}
return undefined
} }
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void { function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
@@ -308,9 +282,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
let record = store.getMessage(messageId) let record = store.getMessage(messageId)
if (!record) { if (!record) {
const pendingId = findPendingSyntheticMessageId(store, sessionId, role) const pendingId = findPendingMessageId(store, sessionId, role)
if (pendingId && pendingId !== messageId) { if (pendingId && pendingId !== messageId) {
replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" }) replaceMessageIdV2(instanceId, pendingId, messageId)
record = store.getMessage(messageId) record = store.getMessage(messageId)
} }
} }
@@ -371,9 +345,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
let record = store.getMessage(messageId) let record = store.getMessage(messageId)
if (!record) { if (!record) {
const pendingId = findPendingSyntheticMessageId(store, sessionId, role) const pendingId = findPendingMessageId(store, sessionId, role)
if (pendingId && pendingId !== messageId) { if (pendingId && pendingId !== messageId) {
replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" }) replaceMessageIdV2(instanceId, pendingId, messageId)
record = store.getMessage(messageId) record = store.getMessage(messageId)
} }
} }
@@ -515,7 +489,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID const sessionId = event.properties?.sessionID
if (!sessionId) return if (!sessionId) return
if (shouldSendOsNotificationForSession("idle", instanceId, sessionId)) { if (shouldSendOsNotification("idle")) {
const title = getInstanceDisplayName(instanceId) const title = getInstanceDisplayName(instanceId)
const label = getSessionTitle(instanceId, sessionId) const label = getSessionTitle(instanceId, sessionId)
const body = label ? `Session "${label}" is idle` : "Session is idle" const body = label ? `Session "${label}" is idle` : "Session is idle"
@@ -630,10 +604,9 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop
addPermissionToQueue(instanceId, permission) addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission) upsertPermissionV2(instanceId, permission)
const sessionId = getPermissionSessionId(permission) if (shouldSendOsNotification("needsInput")) {
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
const title = getInstanceDisplayName(instanceId) const title = getInstanceDisplayName(instanceId)
const sessionId = getPermissionSessionId(permission)
const label = getSessionTitle(instanceId, sessionId) const label = getSessionTitle(instanceId, sessionId)
const body = label ? `Session "${label}" needs permission` : "Session needs permission" const body = label ? `Session "${label}" needs permission` : "Session needs permission"
fireOsNotification({ title, body }) fireOsNotification({ title, body })
@@ -658,10 +631,9 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti
addQuestionToQueue(instanceId, request) addQuestionToQueue(instanceId, request)
upsertQuestionV2(instanceId, request) upsertQuestionV2(instanceId, request)
const sessionId = getQuestionSessionId(request) if (shouldSendOsNotification("needsInput")) {
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
const title = getInstanceDisplayName(instanceId) const title = getInstanceDisplayName(instanceId)
const sessionId = getQuestionSessionId(request)
const label = getSessionTitle(instanceId, sessionId) const label = getSessionTitle(instanceId, sessionId)
const body = label ? `Session "${label}" needs input` : "Session needs input" const body = label ? `Session "${label}" needs input` : "Session needs input"
fireOsNotification({ title, body }) fireOsNotification({ title, body })

View File

@@ -1,6 +1,6 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "opencode" export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode"
const [settingsOpen, setSettingsOpen] = createSignal(false) const [settingsOpen, setSettingsOpen] = createSignal(false)
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance") const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")

View File

@@ -0,0 +1,46 @@
import { createSignal } from "solid-js"
import type { SpeechCapabilitiesResponse } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
const log = getLogger("api")
const [speechCapabilities, setSpeechCapabilities] = createSignal<SpeechCapabilitiesResponse | null>(null)
const [speechCapabilitiesLoading, setSpeechCapabilitiesLoading] = createSignal(false)
const [speechCapabilitiesError, setSpeechCapabilitiesError] = createSignal<string | null>(null)
let speechCapabilitiesPromise: Promise<SpeechCapabilitiesResponse | null> | null = null
async function loadSpeechCapabilities(force = false): Promise<SpeechCapabilitiesResponse | null> {
if (!force && speechCapabilities()) return speechCapabilities()
if (speechCapabilitiesPromise) return speechCapabilitiesPromise
setSpeechCapabilitiesLoading(true)
setSpeechCapabilitiesError(null)
speechCapabilitiesPromise = serverApi
.fetchSpeechCapabilities()
.then((result) => {
setSpeechCapabilities(result)
setSpeechCapabilitiesError(null)
return result
})
.catch((error) => {
log.error("Failed to load speech capabilities", error)
setSpeechCapabilities(null)
setSpeechCapabilitiesError(error instanceof Error ? error.message : String(error))
return null
})
.finally(() => {
setSpeechCapabilitiesLoading(false)
speechCapabilitiesPromise = null
})
return speechCapabilitiesPromise
}
function resetSpeechCapabilities(): void {
setSpeechCapabilities(null)
setSpeechCapabilitiesError(null)
}
export { speechCapabilities, speechCapabilitiesLoading, speechCapabilitiesError, loadSpeechCapabilities, resetSpeechCapabilities }

View File

@@ -170,6 +170,41 @@
color: var(--button-danger-text, var(--text-inverted, #ffffff)); color: var(--button-danger-text, var(--text-inverted, #ffffff));
} }
.prompt-voice-button {
@apply h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
min-width: 2.5rem;
background-color: color-mix(in oklab, var(--surface-secondary) 82%, var(--surface-base));
color: var(--text-secondary);
}
.prompt-voice-button:hover:not(:disabled) {
color: var(--text-primary);
background-color: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-secondary));
@apply scale-105;
}
.prompt-voice-button:active:not(:disabled) {
@apply scale-95;
}
.prompt-voice-button.is-recording {
min-width: 3.5rem;
background-color: color-mix(in oklab, var(--button-danger-bg, rgba(239, 68, 68, 0.85)) 88%, white 12%);
color: var(--button-danger-text, var(--text-inverted, #ffffff));
}
.prompt-voice-button:disabled {
@apply opacity-50 cursor-not-allowed;
}
.prompt-voice-timer {
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1;
color: currentColor;
}
.stop-button:hover:not(:disabled) { .stop-button:hover:not(:disabled) {
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9)); background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
@apply opacity-95 scale-105; @apply opacity-95 scale-105;

View File

@@ -1,58 +1,39 @@
.virtual-follow-list-shell { .message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
background-color: var(--surface-base);
color: inherit;
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
overflow-anchor: none;
}
.message-stream-block {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; gap: 0.0625rem;
min-height: 0;
position: relative; contain: layout paint style;
}
.virtual-item-wrapper {
width: 100%; width: 100%;
} }
.message-stream { .virtual-item-placeholder,
flex: 1;
min-height: 0;
overflow-y: auto;
background-color: var(--surface-base);
color: inherit;
/* Scrolling optimizations */
overscroll-behavior-y: contain;
/* Prevents scroll chaining to parent elements */
will-change: scroll-position;
/* GPU acceleration hint for smoother scrolling */
-webkit-overflow-scrolling: touch;
/* Momentum scrolling on iOS */
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
overflow-anchor: none;
/* Scrollbar styling */
scrollbar-gutter: stable;
}
.virtual-follow-list-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 10;
/* Ensure it doesn't affect layout at all */
height: 0;
overflow: visible;
}
.virtual-follow-list-overlay > * {
pointer-events: auto;
}
.virtual-follow-list-controls-container {
position: absolute;
bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px));
right: var(--space-md);
z-index: 20;
}
.message-stream-placeholder { .message-stream-placeholder {
display: block; display: block;
width: 100%; width: 100%;
position: relative; position: relative;
background-color: transparent; background-color: transparent;
} }
.virtual-item-content {
width: 100%;
position: relative;
}
.virtual-item-content-hidden {
position: absolute;
inset: 0;
visibility: hidden;
pointer-events: none;
}

View File

@@ -47,9 +47,16 @@ declare global {
webkitGetAsEntry?: () => FileSystemEntry | null webkitGetAsEntry?: () => FileSystemEntry | null
} }
interface TauriDialogModule {
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
save?: (options: Record<string, unknown>) => Promise<string | null>
}
interface TauriBridge { interface TauriBridge {
core?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> dialog?: TauriDialogModule
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
} }
} }

View File

@@ -0,0 +1,10 @@
declare module "tauri-plugin-keepawake-api" {
export interface KeepAwakeConfig {
display?: boolean
idle?: boolean
sleep?: boolean
}
export function start(config?: KeepAwakeConfig): Promise<void>
export function stop(): Promise<void>
}

View File

@@ -77,23 +77,23 @@ export default defineConfig({
theme_color: "#1a1a1a", theme_color: "#1a1a1a",
}, },
workbox: { workbox: {
// Workbox defaults to 2 MiB; our main bundle can slightly exceed that. // Workbox defaults to 2 MiB; our main bundle can slightly exceed that.
// This is a build-time limit for the precache manifest, not a hard runtime cap. // This is a build-time limit for the precache manifest, not a hard runtime cap.
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html. // Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
navigateFallback: null, navigateFallback: null,
// Only precache static assets (avoid caching HTML documents / routes). // Only precache static assets (avoid caching HTML documents / routes).
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"], globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
// Monaco assets can be large; cache them at runtime instead. // Monaco assets can be large; cache them at runtime instead.
globIgnores: [ globIgnores: [
"**/*.html", "**/*.html",
"**/assets/*worker-*.js", "**/assets/*worker-*.js",
"**/assets/editor.api-*.js", "**/assets/editor.api-*.js",
"**/monaco/vs/**/*", "**/monaco/vs/**/*",
], ],
// Only cache static UI assets; never cache API traffic. // Only cache static UI assets; never cache API traffic.
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: ({ url, request }) => { urlPattern: ({ url, request }) => {
if (url.pathname.startsWith("/api/")) return false if (url.pathname.startsWith("/api/")) return false
if (request.destination === "document") return false if (request.destination === "document") return false
@@ -134,34 +134,6 @@ export default defineConfig({
main: resolve(__dirname, "./src/renderer/index.html"), main: resolve(__dirname, "./src/renderer/index.html"),
loading: resolve(__dirname, "./src/renderer/loading.html"), loading: resolve(__dirname, "./src/renderer/loading.html"),
}, },
output: {
manualChunks(id) {
const normalizedId = id.replace(/\\/g, "/")
if (normalizedId.includes("/node_modules/@git-diff-view/")) {
return "git-diff-vendor"
}
if (normalizedId.includes("/node_modules/highlight.js/") || normalizedId.includes("/node_modules/lowlight/")) {
return "highlight-vendor"
}
if (normalizedId.includes("/node_modules/fast-diff/")) {
return "fast-diff-vendor"
}
if (normalizedId.includes("/node_modules/monaco-editor/")) {
return "monaco-vendor"
}
if (
normalizedId.includes("/src/components/file-viewer/") ||
normalizedId.includes("/src/lib/monaco/")
) {
return "monaco-viewer"
}
},
},
}, },
}, },
}) })