ui: Linting & Formatting scripts (#26819)

This commit is contained in:
Aleksander Grygier
2026-08-10 08:38:37 +02:00
committed by GitHub
parent 1e396e72a8
commit 92d1bb0c99
538 changed files with 8806 additions and 6036 deletions
+50 -14
View File
@@ -1,14 +1,15 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from 'eslint-plugin-storybook'; import svelteConfig from './svelte.config.js';
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat'; import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js'; import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import perfectionist from 'eslint-plugin-perfectionist';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import storybook from 'eslint-plugin-storybook';
import svelte from 'eslint-plugin-svelte'; import svelte from 'eslint-plugin-svelte';
import globals from 'globals'; import globals from 'globals';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint'; import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
@@ -21,32 +22,67 @@ export default ts.config(
...svelte.configs.prettier, ...svelte.configs.prettier,
{ {
languageOptions: { globals: { ...globals.browser, ...globals.node } }, languageOptions: { globals: { ...globals.browser, ...globals.node } },
plugins: { perfectionist, 'simple-import-sort': simpleImportSort },
rules: { rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
'svelte/no-at-html-tags': 'off',
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
'svelte/no-navigation-without-resolve': 'off',
// Snippet bodies often ignore one or more of the parent's params // Snippet bodies often ignore one or more of the parent's params
// (e.g. `{#snippet children(_meta, ctx)}` when only ctx is read). // (e.g. `{#snippet children(_meta, ctx)}` when only ctx is read).
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'error', 'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' } { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
], ],
// Enforce empty line at end of file // Enforce empty line at end of file
'eol-last': 'error' 'eol-last': 'error',
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
'padding-line-between-statements': [
'error',
// Blank line between function/class declarations.
{ blankLine: 'always', next: ['function', 'class'], prev: ['function', 'class'] },
// Blank line around if blocks (if/else and else if stay one statement).
{ blankLine: 'always', next: '*', prev: 'if' },
{ blankLine: 'always', next: 'if', prev: '*' },
// Blank line after the last declaration in a group. Because the 'never'
// rules below are scoped per declaration kind, a const group and a let
// group get separated by a blank line, while same-kind declarations stay
// together.
{ blankLine: 'always', next: '*', prev: ['const', 'let', 'var'] },
// No blank line between consecutive declarations of the same kind (kept
// last so each takes precedence over the always rule above for matching
// declaration pairs).
{ blankLine: 'never', next: 'const', prev: 'const' },
{ blankLine: 'never', next: 'let', prev: 'let' },
{ blankLine: 'never', next: 'var', prev: 'var' },
// Blank line before a statement that follows another statement in the block
// (works for return/throw/break/continue). A blank line for a terminal
// statement that opens a block body can't be enforced here: Prettier removes
// the leading blank line of a block, so the two formatters would fight.
{ blankLine: 'always', next: ['return', 'throw', 'break', 'continue'], prev: '*' }
],
'perfectionist/sort-objects': ['error', { type: 'natural' }],
// Alphabetical order for variable declarations and object keys
'perfectionist/sort-variable-declarations': ['error', { type: 'natural' }],
// Sort imports alphabetically by module path, and sort named members within
// each statement. A single catch-all group keeps the list flat (no blank-line
// grouping); Prettier normalizes comma spacing afterwards.
'simple-import-sort/imports': ['error', { groups: [['.*']] }],
'svelte/no-at-html-tags': 'off',
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
'svelte/no-navigation-without-resolve': 'off'
} }
}, },
{ {
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'], extraFileExtensions: ['.svelte'],
parser: ts.parser, parser: ts.parser,
projectService: true,
svelteConfig svelteConfig
} }
} }
+232
View File
@@ -39,6 +39,8 @@
"dompurify": "3.4.13", "dompurify": "3.4.13",
"eslint": "9.39.4", "eslint": "9.39.4",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-perfectionist": "^5.10.1",
"eslint-plugin-simple-import-sort": "^14.0.0",
"eslint-plugin-storybook": "10.5.6", "eslint-plugin-storybook": "10.5.6",
"eslint-plugin-svelte": "3.19.0", "eslint-plugin-svelte": "3.19.0",
"fflate": "0.8.3", "fflate": "0.8.3",
@@ -9281,6 +9283,226 @@
"eslint": ">=7.0.0" "eslint": ">=7.0.0"
} }
}, },
"node_modules/eslint-plugin-perfectionist": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.10.1.tgz",
"integrity": "sha512-Kprsp9Us0GqAesYaAIzUViw57xYp5WBqzXrcE0Mtww++E5fexWXYBipMuuD7yvyH4vvpBH0+oJ+OMAmZ0oYXkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "^8.65.0",
"natural-orderby": "^5.0.0"
},
"engines": {
"node": "^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"eslint": "^8.45.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/project-service": {
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz",
"integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.66.0",
"@typescript-eslint/types": "^8.66.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/scope-manager": {
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz",
"integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz",
"integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/types": {
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz",
"integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz",
"integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.66.0",
"@typescript-eslint/tsconfig-utils": "8.66.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/visitor-keys": "8.66.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/utils": {
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz",
"integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.66.0",
"@typescript-eslint/types": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz",
"integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.66.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-perfectionist/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/eslint-plugin-simple-import-sort": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-14.0.0.tgz",
"integrity": "sha512-NUJO0+XFCkk+o5EsAJruTgnfMEpeWrPWeJS15UVF60GgXmqz1BJ9/3hzlvG7lkL8Bubzos5cCLptThbFfPnSMQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"eslint": ">=5.0.0"
}
},
"node_modules/eslint-plugin-storybook": { "node_modules/eslint-plugin-storybook": {
"version": "10.5.6", "version": "10.5.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.6.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.6.tgz",
@@ -13196,6 +13418,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/natural-orderby": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz",
"integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+5 -3
View File
@@ -12,7 +12,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"reset": "rm -rf .svelte-kit node_modules", "reset": "rm -rf .svelte-kit node_modules",
"format": "prettier --write .", "format": "eslint --fix . && prettier --write .",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e", "test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test", "test:e2e": "playwright test",
@@ -36,6 +36,7 @@
"@playwright/test": "1.56.1", "@playwright/test": "1.56.1",
"@storybook/addon-a11y": "10.5.6", "@storybook/addon-a11y": "10.5.6",
"@storybook/addon-docs": "10.5.6", "@storybook/addon-docs": "10.5.6",
"@storybook/addon-mcp": "0.7.0",
"@storybook/addon-svelte-csf": "5.1.2", "@storybook/addon-svelte-csf": "5.1.2",
"@storybook/addon-vitest": "10.5.6", "@storybook/addon-vitest": "10.5.6",
"@storybook/sveltekit": "10.5.6", "@storybook/sveltekit": "10.5.6",
@@ -57,6 +58,8 @@
"dompurify": "3.4.13", "dompurify": "3.4.13",
"eslint": "9.39.4", "eslint": "9.39.4",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-perfectionist": "^5.10.1",
"eslint-plugin-simple-import-sort": "^14.0.0",
"eslint-plugin-storybook": "10.5.6", "eslint-plugin-storybook": "10.5.6",
"eslint-plugin-svelte": "3.19.0", "eslint-plugin-svelte": "3.19.0",
"fflate": "0.8.3", "fflate": "0.8.3",
@@ -99,8 +102,7 @@
"vite-plugin-devtools-json": "0.2.1", "vite-plugin-devtools-json": "0.2.1",
"vitest": "4.1.10", "vitest": "4.1.10",
"vitest-browser-svelte": "2.1.1", "vitest-browser-svelte": "2.1.1",
"workbox-window": "7.4.1", "workbox-window": "7.4.1"
"@storybook/addon-mcp": "0.7.0"
}, },
"overrides": { "overrides": {
"cookie": "1.1.1", "cookie": "1.1.1",
+14 -14
View File
@@ -1,31 +1,31 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: 'tests/e2e',
testMatch: ['**/*.e2e.ts'],
timeout: 30000,
expect: { expect: {
timeout: 5000 timeout: 5000
}, },
fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, fullyParallel: true,
workers: process.env.CI ? 1 : undefined,
reporter: 'line',
use: {
baseURL: 'http://localhost:8181',
trace: 'on-first-retry'
},
projects: [ projects: [
{ {
name: 'chromium', name: 'chromium',
use: { ...devices['Desktop Chrome'] } use: { ...devices['Desktop Chrome'] }
} }
], ],
reporter: 'line',
retries: process.env.CI ? 2 : 0,
testDir: 'tests/e2e',
testMatch: ['**/*.e2e.ts'],
timeout: 30000,
use: {
baseURL: 'http://localhost:8181',
trace: 'on-first-retry'
},
webServer: { webServer: {
command: 'npm run build && npx http-server ./dist -p 8181', command: 'npm run build && npx http-server ./dist -p 8181',
port: 8181, port: 8181,
timeout: 120000, reuseExistingServer: !process.env.CI,
reuseExistingServer: !process.env.CI timeout: 120000
} },
workers: process.env.CI ? 1 : undefined
}); });
+9 -9
View File
@@ -1,6 +1,6 @@
import { defineConfig } from '@vite-pwa/assets-generator/config';
import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa';
import { writeThemeFavicons } from './scripts/favicon-colorize'; import { writeThemeFavicons } from './scripts/favicon-colorize';
import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa';
import { defineConfig } from '@vite-pwa/assets-generator/config';
writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, { writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
@@ -10,18 +10,18 @@ export default defineConfig({
headLinkOptions: { headLinkOptions: {
preset: '2023' preset: '2023'
}, },
images: ['static/favicon-dark.svg'],
preset: { preset: {
transparent: { apple: {
sizes: [], sizes: []
favicons: [[48, 'favicon-dark.ico']],
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
}, },
maskable: { maskable: {
sizes: [] sizes: []
}, },
apple: { transparent: {
favicons: [[48, 'favicon-dark.ico']],
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING,
sizes: [] sizes: []
} }
}, }
images: ['static/favicon-dark.svg']
}); });
+25 -24
View File
@@ -1,3 +1,11 @@
import { writeThemeFavicons } from './scripts/favicon-colorize';
import {
FAVICON_COLORS,
PWA_ASSET_GENERATOR,
PWA_GENERATOR_DEVICES,
THEME_COLORS
} from './src/lib/constants/pwa';
import { SplashOrientation } from './src/lib/enums/splash.enums';
import { import {
combinePresetAndAppleSplashScreens, combinePresetAndAppleSplashScreens,
defineConfig, defineConfig,
@@ -5,14 +13,6 @@ import {
} from '@vite-pwa/assets-generator/config'; } from '@vite-pwa/assets-generator/config';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import {
THEME_COLORS,
PWA_GENERATOR_DEVICES,
PWA_ASSET_GENERATOR,
FAVICON_COLORS
} from './src/lib/constants/pwa';
import { SplashOrientation } from './src/lib/enums/splash.enums';
import { writeThemeFavicons } from './scripts/favicon-colorize';
writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, { writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
@@ -22,6 +22,7 @@ export default defineConfig({
headLinkOptions: { headLinkOptions: {
preset: PWA_ASSET_GENERATOR.LINK_PRESET preset: PWA_ASSET_GENERATOR.LINK_PRESET
}, },
images: ['static/favicon.svg'],
preset: combinePresetAndAppleSplashScreens( preset: combinePresetAndAppleSplashScreens(
{ {
...minimal2023Preset, ...minimal2023Preset,
@@ -32,37 +33,37 @@ export default defineConfig({
} }
}, },
{ {
padding: PWA_ASSET_GENERATOR.SPLASH_PADDING,
resizeOptions: {
background: THEME_COLORS.BACKGROUND_LIGHT,
fit: PWA_ASSET_GENERATOR.FIT_MODE
},
darkResizeOptions: {
background: THEME_COLORS.BACKGROUND_DARK,
fit: PWA_ASSET_GENERATOR.FIT_MODE
},
darkImageResolver: async (imageName: string) => { darkImageResolver: async (imageName: string) => {
if (imageName.endsWith('favicon.svg')) { if (imageName.endsWith('favicon.svg')) {
return readFileSync(resolve('static/favicon-dark.svg')); return readFileSync(resolve('static/favicon-dark.svg'));
} }
}, },
darkResizeOptions: {
background: THEME_COLORS.BACKGROUND_DARK,
fit: PWA_ASSET_GENERATOR.FIT_MODE
},
linkMediaOptions: { linkMediaOptions: {
log: true,
addMediaScreen: PWA_ASSET_GENERATOR.ADD_MEDIA_SCREEN, addMediaScreen: PWA_ASSET_GENERATOR.ADD_MEDIA_SCREEN,
basePath: PWA_ASSET_GENERATOR.BASE_PATH, basePath: PWA_ASSET_GENERATOR.BASE_PATH,
log: true,
xhtml: PWA_ASSET_GENERATOR.XHTML xhtml: PWA_ASSET_GENERATOR.XHTML
}, },
png: {
compressionLevel: PWA_ASSET_GENERATOR.PNG_COMPRESSION_LEVEL,
quality: PWA_ASSET_GENERATOR.PNG_QUALITY
},
name: (landscape, size, dark) => { name: (landscape, size, dark) => {
const orientation = landscape ? SplashOrientation.LANDSCAPE : SplashOrientation.PORTRAIT; const orientation = landscape ? SplashOrientation.LANDSCAPE : SplashOrientation.PORTRAIT;
const darkPrefix = dark ? PWA_ASSET_GENERATOR.DARK_PREFIX : ''; const darkPrefix = dark ? PWA_ASSET_GENERATOR.DARK_PREFIX : '';
return `apple-splash-${orientation}-${darkPrefix}${size.width}x${size.height}.png`; return `apple-splash-${orientation}-${darkPrefix}${size.width}x${size.height}.png`;
},
padding: PWA_ASSET_GENERATOR.SPLASH_PADDING,
png: {
compressionLevel: PWA_ASSET_GENERATOR.PNG_COMPRESSION_LEVEL,
quality: PWA_ASSET_GENERATOR.PNG_QUALITY
},
resizeOptions: {
background: THEME_COLORS.BACKGROUND_LIGHT,
fit: PWA_ASSET_GENERATOR.FIT_MODE
} }
}, },
PWA_GENERATOR_DEVICES PWA_GENERATOR_DEVICES
), )
images: ['static/favicon.svg']
}); });
+16 -10
View File
@@ -4,12 +4,10 @@ import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(HERE, '..'); const PROJECT_ROOT = resolve(HERE, '..');
const DEFAULT_LOGO = resolve(PROJECT_ROOT, 'src/lib/assets/logo.svg'); const DEFAULT_LOGO = resolve(PROJECT_ROOT, 'src/lib/assets/logo.svg');
const DEFAULT_OUT_DIR = resolve(PROJECT_ROOT, 'static'); const DEFAULT_OUT_DIR = resolve(PROJECT_ROOT, 'static');
const DEFAULT_OUT_LIGHT = resolve(DEFAULT_OUT_DIR, 'favicon.svg'); const DEFAULT_OUT_LIGHT = resolve(DEFAULT_OUT_DIR, 'favicon.svg');
const DEFAULT_OUT_DARK = resolve(DEFAULT_OUT_DIR, 'favicon-dark.svg'); const DEFAULT_OUT_DARK = resolve(DEFAULT_OUT_DIR, 'favicon-dark.svg');
const CURRENT_COLOR = 'currentColor'; const CURRENT_COLOR = 'currentColor';
export interface ColorizedFavicon { export interface ColorizedFavicon {
@@ -39,8 +37,8 @@ export function colorizeFaviconSvg(
darkColor: string darkColor: string
): ColorizedFavicon { ): ColorizedFavicon {
return { return {
light: svg.replaceAll(CURRENT_COLOR, lightColor), dark: svg.replaceAll(CURRENT_COLOR, darkColor),
dark: svg.replaceAll(CURRENT_COLOR, darkColor) light: svg.replaceAll(CURRENT_COLOR, lightColor)
}; };
} }
@@ -54,33 +52,40 @@ export function padFaviconSvg(svg: string, padding: number): string {
if (!(padding > 0) || padding >= 1) return svg; if (!(padding > 0) || padding >= 1) return svg;
const viewBoxMatch = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i); const viewBoxMatch = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i);
if (!viewBoxMatch) return svg; if (!viewBoxMatch) return svg;
const parts = viewBoxMatch[1] const parts = viewBoxMatch[1]
.trim() .trim()
.split(/[\s,]+/) .split(/[\s,]+/)
.map(Number); .map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return svg; if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return svg;
const [, , width, height] = parts; const [, , width, height] = parts;
if (width <= 0 || height <= 0) return svg; if (width <= 0 || height <= 0) return svg;
const scale = 1 - padding; const scale = 1 - padding;
const translateX = (padding * width) / 2; const translateX = (padding * width) / 2;
const translateY = (padding * height) / 2; const translateY = (padding * height) / 2;
const openTagStart = svg.search(/<svg\b/i); const openTagStart = svg.search(/<svg\b/i);
if (openTagStart === -1) return svg; if (openTagStart === -1) return svg;
const openTagEnd = svg.indexOf('>', openTagStart); const openTagEnd = svg.indexOf('>', openTagStart);
if (openTagEnd === -1) return svg; if (openTagEnd === -1) return svg;
const closeStart = svg.lastIndexOf('</svg'); const closeStart = svg.lastIndexOf('</svg');
if (closeStart === -1 || closeStart <= openTagEnd) return svg; if (closeStart === -1 || closeStart <= openTagEnd) return svg;
const openTag = svg.slice(0, openTagEnd + 1); const openTag = svg.slice(0, openTagEnd + 1);
const inner = svg.slice(openTagEnd + 1, closeStart); const inner = svg.slice(openTagEnd + 1, closeStart);
const closeTag = svg.slice(closeStart); const closeTag = svg.slice(closeStart);
const group = `<g transform="translate(${translateX} ${translateY}) scale(${scale})">`; const group = `<g transform="translate(${translateX} ${translateY}) scale(${scale})">`;
return `${openTag}${group}${inner}</g>${closeTag}`; return `${openTag}${group}${inner}</g>${closeTag}`;
} }
@@ -93,14 +98,15 @@ export function writeThemeFavicons(
lightColor: string, lightColor: string,
darkColor: string, darkColor: string,
{ {
sourcePath = DEFAULT_LOGO,
lightOutPath = DEFAULT_OUT_LIGHT,
darkOutPath = DEFAULT_OUT_DARK, darkOutPath = DEFAULT_OUT_DARK,
padding = 0 lightOutPath = DEFAULT_OUT_LIGHT,
padding = 0,
sourcePath = DEFAULT_LOGO
}: WriteThemeFaviconsOptions = {} }: WriteThemeFaviconsOptions = {}
): void { ): void {
const source = readFileSync(sourcePath, 'utf-8'); const source = readFileSync(sourcePath, 'utf-8');
const { light, dark } = colorizeFaviconSvg(source, lightColor, darkColor); const { dark, light } = colorizeFaviconSvg(source, lightColor, darkColor);
mkdirSync(dirname(lightOutPath), { recursive: true }); mkdirSync(dirname(lightOutPath), { recursive: true });
writeFileSync(lightOutPath, padFaviconSvg(light, padding)); writeFileSync(lightOutPath, padFaviconSvg(light, padding));
writeFileSync(darkOutPath, padFaviconSvg(dark, padding)); writeFileSync(darkOutPath, padFaviconSvg(dark, padding));
+19 -17
View File
@@ -13,31 +13,28 @@
* maskable-icon and apple-touch-icon are left untouched. * maskable-icon and apple-touch-icon are left untouched.
*/ */
import sharp from 'sharp';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import sharp from 'sharp';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const STATIC_DIR = path.resolve(__dirname, '..', 'static'); const STATIC_DIR = path.resolve(__dirname, '..', 'static');
const paddingPct = process.argv.reduce((acc, arg, i, args) => { const paddingPct = process.argv.reduce((acc, arg, i, args) => {
if (arg === '--padding-pct' && args[i + 1]) return parseFloat(args[i + 1]); if (arg === '--padding-pct' && args[i + 1]) return parseFloat(args[i + 1]);
return acc; return acc;
}, 0); }, 0);
// Scale down the source image before cropping to circle // Scale down the source image before cropping to circle
const scalePct = process.argv.reduce((acc, arg, i, args) => { const scalePct = process.argv.reduce((acc, arg, i, args) => {
if (arg === '--scale-pct' && args[i + 1]) return parseFloat(args[i + 1]); if (arg === '--scale-pct' && args[i + 1]) return parseFloat(args[i + 1]);
return acc; return acc;
}, 85); // default 85% - icon fills 85% of the circular area }, 85); // default 85% - icon fills 85% of the circular area
// Source for circular icons: the maskable icon (white bg, full logo) // Source for circular icons: the maskable icon (white bg, full logo)
const sourceIcon = 'maskable-icon-512x512.png'; const sourceIcon = 'maskable-icon-512x512.png';
const targetIcons = ['pwa-64x64.png', 'pwa-192x192.png', 'pwa-512x512.png']; const targetIcons = ['pwa-64x64.png', 'pwa-192x192.png', 'pwa-512x512.png'];
// maskable-icon and apple-touch-icon stay square // maskable-icon and apple-touch-icon stay square
const untouchedIcons = ['maskable-icon-512x512.png', 'apple-touch-icon-180x180.png']; const untouchedIcons = ['maskable-icon-512x512.png', 'apple-touch-icon-180x180.png'];
@@ -47,10 +44,13 @@ async function makeCircle(targetFilename) {
if (!fs.existsSync(sourcePath)) { if (!fs.existsSync(sourcePath)) {
console.log(`⏭️ ${sourceIcon} not found, skipping`); console.log(`⏭️ ${sourceIcon} not found, skipping`);
return; return;
} }
if (!fs.existsSync(targetPath)) { if (!fs.existsSync(targetPath)) {
console.log(`⏭️ ${targetFilename} not found, skipping`); console.log(`⏭️ ${targetFilename} not found, skipping`);
return; return;
} }
@@ -58,16 +58,18 @@ async function makeCircle(targetFilename) {
const size = Math.max(metadata.width, metadata.height); const size = Math.max(metadata.width, metadata.height);
const radius = Math.floor((size * (1 - paddingPct / 100)) / 2); const radius = Math.floor((size * (1 - paddingPct / 100)) / 2);
const center = Math.floor(size / 2); const center = Math.floor(size / 2);
// Build circular mask as RGBA buffer: white opaque circle on transparent bg // Build circular mask as RGBA buffer: white opaque circle on transparent bg
const maskBuf = Buffer.alloc(size * size * 4, 0); const maskBuf = Buffer.alloc(size * size * 4, 0);
for (let y = 0; y < size; y++) { for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) { for (let x = 0; x < size; x++) {
const dx = x - center; const dx = x - center;
const dy = y - center; const dy = y - center;
const dist = Math.sqrt(dx * dx + dy * dy); const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < radius) { if (dist < radius) {
const i = (y * size + x) * 4; const i = (y * size + x) * 4;
maskBuf[i] = 255; maskBuf[i] = 255;
maskBuf[i + 1] = 255; maskBuf[i + 1] = 255;
maskBuf[i + 2] = 255; maskBuf[i + 2] = 255;
@@ -77,8 +79,9 @@ async function makeCircle(targetFilename) {
} }
const tmpMask = path.join(STATIC_DIR, '.mask-tmp.png'); const tmpMask = path.join(STATIC_DIR, '.mask-tmp.png');
await sharp(maskBuf, { await sharp(maskBuf, {
raw: { width: size, height: size, channels: 4 } raw: { channels: 4, height: size, width: size }
}) })
.png() .png()
.toFile(tmpMask); .toFile(tmpMask);
@@ -87,28 +90,26 @@ async function makeCircle(targetFilename) {
const circleDiameter = Math.floor(size * (1 - paddingPct / 100)); const circleDiameter = Math.floor(size * (1 - paddingPct / 100));
const scaledSize = Math.floor((circleDiameter * scalePct) / 100); const scaledSize = Math.floor((circleDiameter * scalePct) / 100);
const offset = Math.floor((size - scaledSize) / 2); const offset = Math.floor((size - scaledSize) / 2);
const scaledBuf = await sharp(sourcePath) const scaledBuf = await sharp(sourcePath)
.resize(scaledSize, scaledSize, { .resize(scaledSize, scaledSize, {
fit: 'cover', background: { alpha: 1, b: 255, g: 255, r: 255 },
background: { r: 255, g: 255, b: 255, alpha: 1 } fit: 'cover'
}) })
.ensureAlpha() .ensureAlpha()
.png() .png()
.toBuffer(); .toBuffer();
// Step 2: Composite scaled image onto white background, then apply circular mask // Step 2: Composite scaled image onto white background, then apply circular mask
const output = await sharp({ const output = await sharp({
create: { create: {
width: size, background: { alpha: 1, b: 255, g: 255, r: 255 },
height: size,
channels: 4, channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 } height: size,
width: size
} }
}) })
.composite([ .composite([
{ input: scaledBuf, top: offset, left: offset }, { input: scaledBuf, left: offset, top: offset },
{ input: tmpMask, top: 0, left: 0, blend: 'dest-in' } { blend: 'dest-in', input: tmpMask, left: 0, top: 0 }
]) ])
.png() .png()
.toBuffer(); .toBuffer();
@@ -130,6 +131,7 @@ async function main() {
console.log('\nUnchanged:'); console.log('\nUnchanged:');
for (const icon of untouchedIcons) { for (const icon of untouchedIcons) {
const fp = path.join(STATIC_DIR, icon); const fp = path.join(STATIC_DIR, icon);
console.log(` ${icon} (${fs.existsSync(fp) ? fs.statSync(fp).size + ' bytes' : 'missing'})`); console.log(` ${icon} (${fs.existsSync(fp) ? fs.statSync(fp).size + ' bytes' : 'missing'})`);
} }
} }
+7 -5
View File
@@ -1,7 +1,7 @@
import { writeFileSync, existsSync } from 'node:fs'; import { BUILD_CONFIG } from '../src/lib/constants/pwa';
import { existsSync, writeFileSync } from 'node:fs';
import { resolve } from 'path'; import { resolve } from 'path';
import type { Plugin } from 'vite'; import type { Plugin } from 'vite';
import { BUILD_CONFIG } from '../src/lib/constants/pwa';
let processed = false; let processed = false;
@@ -15,27 +15,29 @@ const OUTPUT_DIR = process.env.LLAMA_UI_OUT_DIR ?? BUILD_CONFIG.OUTPUT_DIR;
*/ */
export function buildInfoPlugin(): Plugin { export function buildInfoPlugin(): Plugin {
return { return {
name: 'llamacpp:build-info',
apply: 'build', apply: 'build',
closeBundle() { closeBundle() {
setTimeout(() => { setTimeout(() => {
try { try {
if (processed) return; if (processed) return;
processed = true; processed = true;
const buildNumber = process.env.LLAMA_BUILD_NUMBER || 'b0000'; const buildNumber = process.env.LLAMA_BUILD_NUMBER || 'b0000';
const outDir = resolve(OUTPUT_DIR); const outDir = resolve(OUTPUT_DIR);
const indexPath = resolve(outDir, 'index.html'); const indexPath = resolve(outDir, 'index.html');
if (!existsSync(indexPath)) return; if (!existsSync(indexPath)) return;
const buildJsonPath = resolve(outDir, 'build.json'); const buildJsonPath = resolve(outDir, 'build.json');
writeFileSync(buildJsonPath, JSON.stringify({ version: buildNumber }), 'utf-8'); writeFileSync(buildJsonPath, JSON.stringify({ version: buildNumber }), 'utf-8');
console.log(`Created build.json (version: ${buildNumber})`); console.log(`Created build.json (version: ${buildNumber})`);
} catch (error) { } catch (error) {
console.error('Failed to write build.json:', error); console.error('Failed to write build.json:', error);
} }
}, 100); }, 100);
} },
name: 'llamacpp:build-info'
}; };
} }
+14 -12
View File
@@ -4,7 +4,6 @@ import { fileURLToPath } from 'url';
import type { Plugin } from 'vite'; import type { Plugin } from 'vite';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const VENDORS_DIR = resolve(__dirname, '../src/lib/vendors'); const VENDORS_DIR = resolve(__dirname, '../src/lib/vendors');
const VIRTUAL_ID = 'virtual:nerdamer'; const VIRTUAL_ID = 'virtual:nerdamer';
const RESOLVED_ID = '\0' + VIRTUAL_ID; const RESOLVED_ID = '\0' + VIRTUAL_ID;
@@ -21,29 +20,32 @@ export function nerdamerPlugin(): Plugin {
let bundled: string | null = null; let bundled: string | null = null;
return { return {
name: 'llamacpp:nerdamer',
resolveId(id) {
return id === VIRTUAL_ID ? RESOLVED_ID : undefined;
},
async load(id) { async load(id) {
if (id !== RESOLVED_ID) return undefined; if (id !== RESOLVED_ID) return undefined;
if (bundled === null) { if (bundled === null) {
const result = await build({ const result = await build({
entryPoints: [resolve(VENDORS_DIR, 'nerdamer-prime/all.js')],
bundle: true,
minify: true,
format: 'iife',
globalName: 'nerdamer',
alias: { alias: {
'big-integer': resolve(VENDORS_DIR, 'big-integer/BigInteger.js'), 'big-integer': resolve(VENDORS_DIR, 'big-integer/BigInteger.js'),
'decimal.js': resolve(VENDORS_DIR, 'decimal.js/decimal.js') 'decimal.js': resolve(VENDORS_DIR, 'decimal.js/decimal.js')
}, },
write: false, bundle: true,
logLevel: 'silent' entryPoints: [resolve(VENDORS_DIR, 'nerdamer-prime/all.js')],
format: 'iife',
globalName: 'nerdamer',
logLevel: 'silent',
minify: true,
write: false
}); });
bundled = result.outputFiles[0].text; bundled = result.outputFiles[0].text;
} }
return `export default ${JSON.stringify(bundled)};`; return `export default ${JSON.stringify(bundled)};`;
},
name: 'llamacpp:nerdamer',
resolveId(id) {
return id === VIRTUAL_ID ? RESOLVED_ID : undefined;
} }
}; };
} }
@@ -1,7 +1,7 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { BUILD_CONFIG } from '../src/lib/constants/pwa';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'path'; import { resolve } from 'path';
import type { Plugin } from 'vite'; import type { Plugin } from 'vite';
import { BUILD_CONFIG } from '../src/lib/constants/pwa';
let processed = false; let processed = false;
@@ -11,11 +11,15 @@ function rewrite(path: string, pairs: [string, string][]): void {
if (!existsSync(path)) { if (!existsSync(path)) {
return; return;
} }
const text = readFileSync(path, 'utf-8'); const text = readFileSync(path, 'utf-8');
let out = text; let out = text;
for (const [from, to] of pairs) { for (const [from, to] of pairs) {
out = out.split(from).join(to); out = out.split(from).join(to);
} }
if (out !== text) { if (out !== text) {
writeFileSync(path, out, 'utf-8'); writeFileSync(path, out, 'utf-8');
} }
@@ -32,12 +36,12 @@ function rewrite(path: string, pairs: [string, string][]): void {
*/ */
export function relativizeBasePlugin(): Plugin { export function relativizeBasePlugin(): Plugin {
return { return {
name: 'llamacpp:relativize-base',
apply: 'build', apply: 'build',
closeBundle() { closeBundle() {
setTimeout(() => { setTimeout(() => {
try { try {
if (processed) return; if (processed) return;
processed = true; processed = true;
const outDir = resolve(OUTPUT_DIR); const outDir = resolve(OUTPUT_DIR);
@@ -56,6 +60,7 @@ export function relativizeBasePlugin(): Plugin {
console.error('Failed to relativize base refs:', error); console.error('Failed to relativize base refs:', error);
} }
}, 100); }, 100);
} },
name: 'llamacpp:relativize-base'
}; };
} }
+23 -13
View File
@@ -1,10 +1,10 @@
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; import { NEWLINE, TAB } from '../src/lib/constants/code';
import { APPLE_DEVICES, BUILD_CONFIG, REGEX_PATTERNS, SPLASH_LINK } from '../src/lib/constants/pwa';
import { SplashOrientation } from '../src/lib/enums/splash.enums';
import type { SplashDimensions } from '../src/lib/types';
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'path'; import { resolve } from 'path';
import type { Plugin } from 'vite'; import type { Plugin } from 'vite';
import { TAB, NEWLINE } from '../src/lib/constants/code';
import { APPLE_DEVICES, BUILD_CONFIG, REGEX_PATTERNS, SPLASH_LINK } from '../src/lib/constants/pwa';
import type { SplashDimensions } from '../src/lib/types';
import { SplashOrientation } from '../src/lib/enums/splash.enums';
let processed = false; let processed = false;
@@ -16,23 +16,26 @@ const OUTPUT_DIR = process.env.LLAMA_UI_OUT_DIR ?? BUILD_CONFIG.OUTPUT_DIR;
*/ */
export function generateSplashScreenLinks(outDir: string): string[] { export function generateSplashScreenLinks(outDir: string): string[] {
const files = readdirSync(outDir).filter((f) => f.match(REGEX_PATTERNS.SPLASH_FILE)); const files = readdirSync(outDir).filter((f) => f.match(REGEX_PATTERNS.SPLASH_FILE));
if (files.length === 0) return []; if (files.length === 0) return [];
const dimMap = new Map<string, SplashDimensions>(); const dimMap = new Map<string, SplashDimensions>();
for (const [dims, spec] of Object.entries(APPLE_DEVICES)) { for (const [dims, spec] of Object.entries(APPLE_DEVICES)) {
const [w, h] = dims.split('x').map(Number); const [w, h] = dims.split('x').map(Number);
// logical-point dimensions // logical-point dimensions
dimMap.set(`${w}x${h}`, { deviceW: spec.width, deviceH: spec.height, dpr: spec.dpr }); dimMap.set(`${w}x${h}`, { deviceH: spec.height, deviceW: spec.width, dpr: spec.dpr });
dimMap.set(`${h}x${w}`, { deviceW: spec.width, deviceH: spec.height, dpr: spec.dpr }); dimMap.set(`${h}x${w}`, { deviceH: spec.height, deviceW: spec.width, dpr: spec.dpr });
// pixel dimensions (used by actual generated splash files) // pixel dimensions (used by actual generated splash files)
dimMap.set(`${w * spec.dpr}x${h * spec.dpr}`, { dimMap.set(`${w * spec.dpr}x${h * spec.dpr}`, {
deviceW: spec.width,
deviceH: spec.height, deviceH: spec.height,
deviceW: spec.width,
dpr: spec.dpr dpr: spec.dpr
}); });
dimMap.set(`${h * spec.dpr}x${w * spec.dpr}`, { dimMap.set(`${h * spec.dpr}x${w * spec.dpr}`, {
deviceW: spec.width,
deviceH: spec.height, deviceH: spec.height,
deviceW: spec.width,
dpr: spec.dpr dpr: spec.dpr
}); });
} }
@@ -42,20 +45,23 @@ export function generateSplashScreenLinks(outDir: string): string[] {
for (const file of files) { for (const file of files) {
const match = file.match(REGEX_PATTERNS.SPLASH_FILE); const match = file.match(REGEX_PATTERNS.SPLASH_FILE);
if (!match) continue; if (!match) continue;
const orientation = match[1] as SplashOrientation; const orientation = match[1] as SplashOrientation;
const isDark = !!match[2]; const isDark = !!match[2];
const pixelW = parseInt(match[3]); const pixelW = parseInt(match[3]);
const pixelH = parseInt(match[4]); const pixelH = parseInt(match[4]);
const key = `${pixelW}x${pixelH}`; const key = `${pixelW}x${pixelH}`;
const spec = dimMap.get(key); const spec = dimMap.get(key);
if (!spec) { if (!spec) {
console.warn(`Unknown splash screen dimensions: ${key} (${file})`); console.warn(`Unknown splash screen dimensions: ${key} (${file})`);
continue; continue;
} }
const { deviceW, deviceH, dpr } = spec; const { deviceH, deviceW, dpr } = spec;
const media = `screen and (device-width: ${deviceW}px) and (device-height: ${deviceH}px) and (-webkit-device-pixel-ratio: ${dpr}) and (orientation: ${orientation})`; const media = `screen and (device-width: ${deviceW}px) and (device-height: ${deviceH}px) and (-webkit-device-pixel-ratio: ${dpr}) and (orientation: ${orientation})`;
const href = `./${file}`; const href = `./${file}`;
@@ -73,16 +79,17 @@ export function generateSplashScreenLinks(outDir: string): string[] {
export function splashScreenPlugin(): Plugin { export function splashScreenPlugin(): Plugin {
return { return {
name: 'llamacpp:splash-screen',
apply: 'build', apply: 'build',
closeBundle() { closeBundle() {
setTimeout(() => { setTimeout(() => {
try { try {
if (processed) return; if (processed) return;
processed = true; processed = true;
const outDir = resolve(OUTPUT_DIR); const outDir = resolve(OUTPUT_DIR);
const indexPath = resolve(outDir, 'index.html'); const indexPath = resolve(outDir, 'index.html');
if (!existsSync(indexPath)) return; if (!existsSync(indexPath)) return;
let content = readFileSync(indexPath, 'utf-8'); let content = readFileSync(indexPath, 'utf-8');
@@ -91,9 +98,11 @@ export function splashScreenPlugin(): Plugin {
// The @vite-pwa/assets-generator generates apple-splash-*.png files; // The @vite-pwa/assets-generator generates apple-splash-*.png files;
// this scans them and creates the <link> tags SvelteKit needs. // this scans them and creates the <link> tags SvelteKit needs.
const splashLinks = generateSplashScreenLinks(outDir); const splashLinks = generateSplashScreenLinks(outDir);
if (splashLinks.length > 0) { if (splashLinks.length > 0) {
console.log(`Generated ${splashLinks.length} apple-splash link tags`); console.log(`Generated ${splashLinks.length} apple-splash link tags`);
const splashHtml = splashLinks.map((l) => TAB + TAB + l).join(NEWLINE); const splashHtml = splashLinks.map((l) => TAB + TAB + l).join(NEWLINE);
content = content.replace( content = content.replace(
REGEX_PATTERNS.HEAD_CLOSE, REGEX_PATTERNS.HEAD_CLOSE,
splashHtml + NEWLINE + TAB + TAB + '</head>' splashHtml + NEWLINE + TAB + TAB + '</head>'
@@ -110,6 +119,7 @@ export function splashScreenPlugin(): Plugin {
console.error('Failed to process build output:', error); console.error('Failed to process build output:', error);
} }
}, 100); }, 100);
} },
name: 'llamacpp:splash-screen'
}; };
} }
+14 -17
View File
@@ -3,9 +3,8 @@
import 'vite-plugin-pwa/pwa-assets'; import 'vite-plugin-pwa/pwa-assets';
import 'vite-plugin-pwa/svelte'; import 'vite-plugin-pwa/svelte';
import { ModelModality, ServerModelStatus, ServerRole } from '$lib/enums';
// Import chat types from dedicated module // Import chat types from dedicated module
import type { import type {
// API types // API types
ApiChatCompletionRequest, ApiChatCompletionRequest,
@@ -13,59 +12,57 @@ import type {
ApiChatCompletionStreamChunk, ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall, ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta, ApiChatCompletionToolCallDelta,
ApiChatMessageData,
ApiChatMessageContentPart, ApiChatMessageContentPart,
ApiChatMessageData,
ApiContextSizeError, ApiContextSizeError,
ApiErrorResponse, ApiErrorResponse,
ApiLlamaCppServerProps, ApiLlamaCppServerProps,
ApiModelDataEntry, ApiModelDataEntry,
ApiModelListResponse,
ApiModelLoadStage, ApiModelLoadStage,
ApiModelsSseProgress,
ApiModelsSseData, ApiModelsSseData,
ApiModelsSseEvent, ApiModelsSseEvent,
ApiModelListResponse, ApiModelsSseProgress,
ApiProcessingState, ApiProcessingState,
ApiRouterModelMeta, ApiRouterModelMeta,
ApiRouterModelsListResponse,
ApiRouterModelsLoadRequest, ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse, ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest, ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse, ApiRouterModelsStatusResponse,
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest, ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse, ApiRouterModelsUnloadResponse,
// Chat types // Chat types
ChatAttachmentDisplayItem, ChatAttachmentDisplayItem,
ChatMessagePromptProgress,
ChatMessageSiblingInfo,
ChatMessageTimings,
ChatMessageType, ChatMessageType,
ChatRole, ChatRole,
ChatUploadedFile, ChatUploadedFile,
ChatMessageSiblingInfo,
ChatMessagePromptProgress,
ChatMessageTimings,
// Database types // Database types
DatabaseConversation, DatabaseConversation,
DatabaseMessage, DatabaseMessage,
DatabaseMessageExtra, DatabaseMessageExtra,
DatabaseMessageExtraAudioFile, DatabaseMessageExtraAudioFile,
DatabaseMessageExtraVideoFile,
DatabaseMessageExtraImageFile, DatabaseMessageExtraImageFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraLegacyContext, DatabaseMessageExtraLegacyContext,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraVideoFile,
ExportedConversation, ExportedConversation,
ExportedConversations, ExportedConversations,
ModelLoadProgress,
// Model types // Model types
ModelModalities, ModelModalities,
ModelOption, ModelOption,
ModelLoadProgress,
// Settings types // Settings types
SettingsChatServiceOptions, SettingsChatServiceOptions,
SettingsConfigType,
SettingsConfigValue, SettingsConfigValue,
SettingsFieldConfig, SettingsFieldConfig
SettingsConfigType
} from '$lib/types'; } from '$lib/types';
import { ServerRole, ServerModelStatus, ModelModality } from '$lib/enums';
declare global { declare global {
// namespace App { // namespace App {
// interface Error {} // interface Error {}
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Button, type ButtonVariant, type ButtonSize } from '$lib/components/ui/button'; import { Button, type ButtonSize, type ButtonVariant } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import type { Component } from 'svelte';
import { TooltipSide } from '$lib/enums'; import { TooltipSide } from '$lib/enums';
import type { Component } from 'svelte';
interface Props { interface Props {
ariaLabel?: string; ariaLabel?: string;
@@ -20,18 +20,18 @@
} }
let { let {
icon, ariaLabel,
tooltip,
variant = 'ghost',
href = '',
size = 'sm',
class: className = '', class: className = '',
disabled = false, disabled = false,
href = '',
icon,
iconSize = 'h-3 w-3', iconSize = 'h-3 w-3',
tooltipSide = TooltipSide.TOP,
stopPropagationOnClick = false,
onclick, onclick,
ariaLabel size = 'sm',
stopPropagationOnClick = false,
tooltip,
tooltipSide = TooltipSide.TOP,
variant = 'ghost'
}: Props = $props(); }: Props = $props();
let innerWidth = $state(0); let innerWidth = $state(0);
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Copy } from '@lucide/svelte';
import { copyToClipboard } from '$lib/utils';
import ActionIcon from './ActionIcon.svelte'; import ActionIcon from './ActionIcon.svelte';
import { Copy } from '@lucide/svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { copyToClipboard } from '$lib/utils';
export let ariaLabel: string = 'Copy to clipboard'; export let ariaLabel: string = 'Copy to clipboard';
export let canCopy: boolean = true; export let canCopy: boolean = true;
@@ -7,7 +7,7 @@
class?: string; class?: string;
} }
let { modalities, class: className = '' }: Props = $props(); let { class: className = '', modalities }: Props = $props();
</script> </script>
{#each modalities as modality (modality)} {#each modalities as modality (modality)}
@@ -28,18 +28,18 @@
} }
let { let {
class: className = '', activeModelId,
style = '',
attachments = [], attachments = [],
readonly = false, class: className = '',
onFileRemove,
uploadedFiles = $bindable([]),
// Default to small size for form previews // Default to small size for form previews
imageClass = '', imageClass = '',
imageHeight = 'h-24', imageHeight = 'h-24',
imageWidth = 'w-auto', imageWidth = 'w-auto',
limitToSingleRow = false, limitToSingleRow = false,
activeModelId onFileRemove,
readonly = false,
style = '',
uploadedFiles = $bindable([])
}: Props = $props(); }: Props = $props();
let carouselRef: HorizontalScrollCarousel | undefined = $state(); let carouselRef: HorizontalScrollCarousel | undefined = $state();
@@ -48,7 +48,7 @@
let previewFocusIndex = $state(0); let previewFocusIndex = $state(0);
let viewAllDialogOpen = $state(false); let viewAllDialogOpen = $state(false);
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments })); let displayItems = $derived(getAttachmentDisplayItems({ attachments, uploadedFiles }));
function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) { function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
event?.stopPropagation(); event?.stopPropagation();
@@ -2,8 +2,8 @@
import { import {
ChatAttachmentsListItemMcpPrompt, ChatAttachmentsListItemMcpPrompt,
ChatAttachmentsListItemMcpResource, ChatAttachmentsListItemMcpResource,
ChatAttachmentsListItemThumbnailImage, ChatAttachmentsListItemThumbnailFile,
ChatAttachmentsListItemThumbnailFile ChatAttachmentsListItemThumbnailImage
} from '$lib/components/app'; } from '$lib/components/app';
import { AttachmentType } from '$lib/enums'; import { AttachmentType } from '$lib/enums';
import type { import type {
@@ -49,10 +49,10 @@
return { return {
id, id,
resource: { resource: {
uri: extra.uri,
name: extra.name, name: extra.name,
serverName: extra.serverName,
title: extra.name, title: extra.name,
serverName: extra.serverName uri: extra.uri
} }
}; };
} }
@@ -64,12 +64,12 @@
? (item.attachment as DatabaseMessageExtraMcpPrompt) ? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt : item.uploadedFile?.mcpPrompt
? { ? {
type: AttachmentType.MCP_PROMPT as const, arguments: item.uploadedFile.mcpPrompt.arguments,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '', content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments name: item.name,
promptName: item.uploadedFile.mcpPrompt.promptName,
serverName: item.uploadedFile.mcpPrompt.serverName,
type: AttachmentType.MCP_PROMPT as const
} }
: null} : null}
{#if mcpPrompt} {#if mcpPrompt}
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ChatMessageMcpPromptContent, ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte'; import { X } from '@lucide/svelte';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types'; import { ActionIcon, ChatMessageMcpPromptContent } from '$lib/components/app';
import { McpPromptVariant } from '$lib/enums'; import { McpPromptVariant } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props { interface Props {
class?: string; class?: string;
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Loader2, AlertCircle } from '@lucide/svelte'; import { AlertCircle, Loader2 } from '@lucide/svelte';
import { X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { mcpStore } from '$lib/stores/mcp.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceAttachment } from '$lib/types'; import type { MCPResourceAttachment } from '$lib/types';
import * as Tooltip from '$lib/components/ui/tooltip'; import { getResourceDisplayName, getResourceIcon } from '$lib/utils';
import { ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
import { getResourceIcon, getResourceDisplayName } from '$lib/utils';
interface Props { interface Props {
attachment: MCPResourceAttachment; attachment: MCPResourceAttachment;
@@ -24,6 +24,7 @@
function getStatusClass(attachment: MCPResourceAttachment): string { function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10'; if (attachment.error) return 'border-red-500/50 bg-red-500/10';
if (attachment.loading) return 'border-border/50 bg-muted/30'; if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30'; return 'border-border/50 bg-muted/30';
@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Music, Video, X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { X, Music, Video } from '@lucide/svelte'; import { AttachmentType } from '$lib/enums';
import { import {
formatFileSize, formatFileSize,
getFileTypeLabel, getFileTypeLabel,
getPreviewText, getPreviewText,
isPdfFile,
isAudioFile, isAudioFile,
isVideoFile, isPdfFile,
isTextFile isTextFile,
isVideoFile
} from '$lib/utils'; } from '$lib/utils';
import { ActionIcon } from '$lib/components/app';
import { AttachmentType } from '$lib/enums';
interface Props { interface Props {
attachment?: DatabaseMessageExtra; attachment?: DatabaseMessageExtra;
@@ -31,9 +31,9 @@
attachment, attachment,
class: className = '', class: className = '',
id, id,
name,
onclick, onclick,
onRemove, onRemove,
name,
readonly = false, readonly = false,
size, size,
textContent, textContent,
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte'; import { X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
interface Props { interface Props {
class?: string; class?: string;
@@ -20,9 +20,9 @@
height = 'h-16', height = 'h-16',
id, id,
imageClass = '', imageClass = '',
name,
onclick, onclick,
onRemove, onRemove,
name,
preview, preview,
readonly = false, readonly = false,
width = 'w-auto' width = 'w-auto'
@@ -12,12 +12,12 @@
getAttachmentDisplayItems, getAttachmentDisplayItems,
getLanguageFromFilename, getLanguageFromFilename,
isAudioFile, isAudioFile,
isVideoFile,
isImageFile, isImageFile,
isMcpPrompt, isMcpPrompt,
isMcpResource, isMcpResource,
isPdfFile, isPdfFile,
isTextFile isTextFile,
isVideoFile
} from '$lib/utils'; } from '$lib/utils';
interface PreviewItem { interface PreviewItem {
@@ -42,21 +42,21 @@
} }
let { let {
uploadedFiles = [],
attachments = [],
activeModelId, activeModelId,
attachments = [],
class: className = '', class: className = '',
previewFocusIndex = 0 previewFocusIndex = 0,
uploadedFiles = []
}: Props = $props(); }: Props = $props();
let allItems = $derived( let allItems = $derived(
getAttachmentDisplayItems({ uploadedFiles, attachments }) getAttachmentDisplayItems({ attachments, uploadedFiles })
.filter((item) => !isMcpPrompt(item) && !isMcpResource(item)) .filter((item) => !isMcpPrompt(item) && !isMcpResource(item))
.map( .map(
(item): PreviewItem => ({ (item): PreviewItem => ({
...item, ...item,
isImage: isImageFile(item.attachment, item.uploadedFile),
isAudio: isAudioFile(item.attachment, item.uploadedFile), isAudio: isAudioFile(item.attachment, item.uploadedFile),
isImage: isImageFile(item.attachment, item.uploadedFile),
isVideo: isVideoFile(item.attachment, item.uploadedFile) isVideo: isVideoFile(item.attachment, item.uploadedFile)
}) })
) )
@@ -88,10 +88,11 @@
$effect(() => { $effect(() => {
const index = currentIndex; const index = currentIndex;
setTimeout(() => { setTimeout(() => {
const thumbnail = document.querySelector(`[data-thumbnail-index="${index}"]`); const thumbnail = document.querySelector(`[data-thumbnail-index="${index}"]`);
thumbnail?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); thumbnail?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}, 0); }, 0);
}); });
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { Image, Music, Video, FileText, FileIcon } from '@lucide/svelte';
import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte'; import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte';
import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte'; import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte'; import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte';
import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte'; import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte';
import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
import { FileIcon, FileText, Image, Music, Video } from '@lucide/svelte';
import type { ChatAttachmentDisplayItem } from '$lib/types';
interface Props { interface Props {
currentItem: ChatAttachmentDisplayItem | null; currentItem: ChatAttachmentDisplayItem | null;
@@ -25,19 +25,19 @@
} }
let { let {
activeModelId,
audioSrc,
currentItem, currentItem,
isImage,
isAudio,
isVideo,
isPdf,
isText,
displayPreview, displayPreview,
displayTextContent, displayTextContent,
audioSrc,
videoSrc,
language,
hasVisionModality, hasVisionModality,
activeModelId isAudio,
isImage,
isPdf,
isText,
isVideo,
language,
videoSrc
}: Props = $props(); }: Props = $props();
let IconComponent = $derived( let IconComponent = $derived(
@@ -6,7 +6,7 @@
audioSrc: string | null; audioSrc: string | null;
} }
let { currentItem, audioSrc }: Props = $props(); let { audioSrc, currentItem }: Props = $props();
</script> </script>
<div class="flex flex-1 items-center justify-center p-8"> <div class="flex flex-1 items-center justify-center p-8">
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { Eye, FileText, Info } from '@lucide/svelte';
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { FileText, Eye, Info } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Alert from '$lib/components/ui/alert';
import { SyntaxHighlightedCode } from '$lib/components/app'; import { SyntaxHighlightedCode } from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { PdfViewMode } from '$lib/enums';
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { getLanguageFromFilename } from '$lib/utils'; import { getLanguageFromFilename } from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only'; import { convertPDFToImage } from '$lib/utils/browser-only';
import { PdfViewMode } from '$lib/enums';
interface Props { interface Props {
currentItem: ChatAttachmentDisplayItem | null; currentItem: ChatAttachmentDisplayItem | null;
@@ -17,7 +17,7 @@
activeModelId?: string; activeModelId?: string;
} }
let { currentItem, displayName, displayTextContent, hasVisionModality, activeModelId }: Props = let { activeModelId, currentItem, displayName, displayTextContent, hasVisionModality }: Props =
$props(); $props();
let pdfViewMode = $state<PdfViewMode>(PdfViewMode.PAGES); let pdfViewMode = $state<PdfViewMode>(PdfViewMode.PAGES);
@@ -47,6 +47,7 @@
currentItem.attachment.images.length > 0 currentItem.attachment.images.length > 0
) { ) {
pdfImages = currentItem.attachment.images; pdfImages = currentItem.attachment.images;
return; return;
} }
@@ -55,10 +56,12 @@
const base64Data = currentItem.attachment.base64Data; const base64Data = currentItem.attachment.base64Data;
const byteCharacters = atob(base64Data); const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length); const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) { for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i); byteNumbers[i] = byteCharacters.charCodeAt(i);
} }
const byteArray = new Uint8Array(byteNumbers); const byteArray = new Uint8Array(byteNumbers);
file = new File([byteArray], displayName, { type: 'application/pdf' }); file = new File([byteArray], displayName, { type: 'application/pdf' });
} }
} }
@@ -8,7 +8,7 @@
show: boolean; show: boolean;
} }
let { onPrev, onNext, show }: Props = $props(); let { onNext, onPrev, show }: Props = $props();
</script> </script>
{#if show} {#if show}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { FileText, Music, Video } from '@lucide/svelte';
import { Music, Video, FileText } from '@lucide/svelte';
import { HorizontalScrollCarousel } from '$lib/components/app/misc'; import { HorizontalScrollCarousel } from '$lib/components/app/misc';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface PreviewItem { interface PreviewItem {
id: string; id: string;
@@ -18,13 +18,15 @@
onNavigate: (index: number) => void; onNavigate: (index: number) => void;
} }
let { items, currentIndex, onNavigate }: Props = $props(); let { currentIndex, items, onNavigate }: Props = $props();
function getFileExtension(name: string): string { function getFileExtension(name: string): string {
const parts = name.split('.'); const parts = name.split('.');
if (parts.length > 1) { if (parts.length > 1) {
return parts.pop()?.toUpperCase() ?? ''; return parts.pop()?.toUpperCase() ?? '';
} }
return ''; return '';
} }
</script> </script>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
import { import {
ChatAttachmentsList, ChatAttachmentsList,
ChatFormActions, ChatFormActions,
@@ -12,10 +13,10 @@
} from '$lib/components/app'; } from '$lib/components/app';
import { import {
CLIPBOARD_CONTENT_QUOTE_PREFIX, CLIPBOARD_CONTENT_QUOTE_PREFIX,
INPUT_CLASSES,
SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE, INITIAL_FILE_SIZE,
PROMPT_CONTENT_SEPARATOR INPUT_CLASSES,
PROMPT_CONTENT_SEPARATOR,
SETTING_CONFIG_DEFAULT
} from '$lib/constants'; } from '$lib/constants';
import { import {
ContentPartType, ContentPartType,
@@ -24,20 +25,20 @@
MimeTypeText, MimeTypeText,
SpecialFileType SpecialFileType
} from '$lib/enums'; } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte'; import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { import {
conversationsStore,
activeMessages,
activeConversation, activeConversation,
activeMessages,
conversationsStore,
pendingCwd pendingCwd
} from '$lib/stores/conversations.svelte'; } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { config } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { import type {
FileMentionEntry, FileMentionEntry,
GetPromptResult, GetPromptResult,
@@ -56,7 +57,6 @@
parseClipboardContent, parseClipboardContent,
uuid uuid
} from '$lib/utils'; } from '$lib/utils';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import { import {
AudioRecorder, AudioRecorder,
convertToWav, convertToWav,
@@ -96,12 +96,6 @@
class: className = '', class: className = '',
disabled = false, disabled = false,
isLoading = false, isLoading = false,
placeholder = 'Type a message...',
showMcpPromptButton = false,
showAddButton = true,
showModelSelector = true,
uploadedFiles = $bindable([]),
value = $bindable(''),
onAttachmentRemove, onAttachmentRemove,
onFilesAdd, onFilesAdd,
onStop, onStop,
@@ -109,7 +103,13 @@
onSystemPromptClick, onSystemPromptClick,
onUploadedFileRemove, onUploadedFileRemove,
onUploadedFilesChange, onUploadedFilesChange,
onValueChange onValueChange,
placeholder = 'Type a message...',
showAddButton = true,
showMcpPromptButton = false,
showModelSelector = true,
uploadedFiles = $bindable([]),
value = $bindable('')
}: Props = $props(); }: Props = $props();
// Component References // Component References
@@ -146,32 +146,35 @@
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd()); let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
const pickers = useChatFormPickers({ const pickers = useChatFormPickers({
focusInput: refocusInput,
getCaretOffset: () => inputRef?.getCaretOffset(),
getCwd: () => cwd,
getPickersRef: () => pickersRef,
getServerHome: () => toolsStore.serverHome ?? null,
getShowModelSelector: () => showModelSelector,
getValue: () => value, getValue: () => value,
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => { setValue: (v) => {
value = v; value = v;
onValueChange?.(v); onValueChange?.(v);
}, }
getCaretOffset: () => inputRef?.getCaretOffset(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
focusInput: refocusInput,
getShowModelSelector: () => showModelSelector,
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
getCwd: () => cwd,
getServerHome: () => toolsStore.serverHome ?? null,
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
getPickersRef: () => pickersRef
}); });
async function handleWorkingDirectoryChange(newDir: string | null) { async function handleWorkingDirectoryChange(newDir: string | null) {
// Committing a directory consumes the `/cwd` token; the chip's // Committing a directory consumes the `/cwd` token; the chip's
// clear-X path has no token to consume. // clear-X path has no token to consume.
const token = findCommandToken(value); const token = findCommandToken(value);
if (token && token.name === 'cwd') { if (token && token.name === 'cwd') {
value = ''; value = '';
onValueChange?.(''); onValueChange?.('');
} }
await conversationsStore.setCwd(newDir); await conversationsStore.setCwd(newDir);
if (conversationsStore.activeConversation) { if (conversationsStore.activeConversation) {
await chatStore.recordCwdChange(newDir?.trim() || null); await chatStore.recordCwdChange(newDir?.trim() || null);
} }
@@ -185,6 +188,7 @@
let pasteLongTextToFileLength = $derived.by(() => { let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen); const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n; return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
}); });
@@ -200,13 +204,16 @@
} }
const selectedId = selectedModelId(); const selectedId = selectedModelId();
if (selectedId) { if (selectedId) {
const model = options.find((m) => m.id === selectedId); const model = options.find((m) => m.id === selectedId);
if (model) return model.model; if (model) return model.model;
} }
if (conversationModel) { if (conversationModel) {
const model = options.find((m) => m.model === conversationModel); const model = options.find((m) => m.model === conversationModel);
if (model) return model.model; if (model) return model.model;
} }
@@ -238,6 +245,7 @@
$effect(() => { $effect(() => {
const wantContenteditable = const wantContenteditable =
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? ''); containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
if (useContenteditable === wantContenteditable) return; if (useContenteditable === wantContenteditable) return;
if (!caretOffsetPinned) { if (!caretOffsetPinned) {
@@ -268,8 +276,10 @@
export function checkModelSelected(): boolean { export function checkModelSelected(): boolean {
if (!hasModelSelected) { if (!hasModelSelected) {
chatFormActionsRef?.openModelSelector(); chatFormActionsRef?.openModelSelector();
return false; return false;
} }
return true; return true;
} }
@@ -284,6 +294,7 @@
function handleFileRemove(fileId: string) { function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) { if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10); const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) { if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index); onAttachmentRemove?.(index);
} }
@@ -333,6 +344,7 @@
if (files.length > 0) { if (files.length > 0) {
event.preventDefault(); event.preventDefault();
onFilesAdd?.(files); onFilesAdd?.(files);
return; return;
} }
@@ -354,26 +366,27 @@
type: MimeTypeText.PLAIN type: MimeTypeText.PLAIN
}) })
); );
onFilesAdd?.(attachmentFiles); onFilesAdd?.(attachmentFiles);
} }
// Handle MCP prompt attachments as ChatUploadedFile with mcpPrompt data // Handle MCP prompt attachments as ChatUploadedFile with mcpPrompt data
if (parsed.mcpPromptAttachments.length > 0) { if (parsed.mcpPromptAttachments.length > 0) {
const mcpPromptFiles: ChatUploadedFile[] = parsed.mcpPromptAttachments.map((att) => ({ const mcpPromptFiles: ChatUploadedFile[] = parsed.mcpPromptAttachments.map((att) => ({
id: uuid(),
name: att.name,
size: att.content.length,
type: SpecialFileType.MCP_PROMPT,
file: new File([att.content], `${att.name}${FileExtensionText.TXT}`, { file: new File([att.content], `${att.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN type: MimeTypeText.PLAIN
}), }),
id: uuid(),
isLoading: false, isLoading: false,
textContent: att.content,
mcpPrompt: { mcpPrompt: {
serverName: att.serverName, arguments: att.arguments,
promptName: att.promptName, promptName: att.promptName,
arguments: att.arguments serverName: att.serverName
} },
name: att.name,
size: att.content.length,
textContent: att.content,
type: SpecialFileType.MCP_PROMPT
})); }));
uploadedFiles = [...uploadedFiles, ...mcpPromptFiles]; uploadedFiles = [...uploadedFiles, ...mcpPromptFiles];
@@ -412,17 +425,17 @@
const promptName = promptInfo.title || promptInfo.name; const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = { const placeholder: ChatUploadedFile = {
id: placeholderId,
name: promptName,
size: INITIAL_FILE_SIZE,
type: SpecialFileType.MCP_PROMPT,
file: new File([], 'loading'), file: new File([], 'loading'),
id: placeholderId,
isLoading: true, isLoading: true,
mcpPrompt: { mcpPrompt: {
serverName: promptInfo.serverName, arguments: args ? { ...args } : undefined,
promptName: promptInfo.name, promptName: promptInfo.name,
arguments: args ? { ...args } : undefined serverName: promptInfo.serverName
} },
name: promptName,
size: INITIAL_FILE_SIZE,
type: SpecialFileType.MCP_PROMPT
}; };
uploadedFiles = [...uploadedFiles, placeholder]; uploadedFiles = [...uploadedFiles, placeholder];
@@ -450,12 +463,12 @@
f.id === placeholderId f.id === placeholderId
? { ? {
...f, ...f,
isLoading: false,
textContent: promptText,
size: promptText.length,
file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, { file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN type: MimeTypeText.PLAIN
}) }),
isLoading: false,
size: promptText.length,
textContent: promptText
} }
: f : f
); );
@@ -480,9 +493,11 @@
function handleMentionSelect(entry: FileMentionEntry) { function handleMentionSelect(entry: FileMentionEntry) {
const cursor = inputRef?.getCaretOffset() ?? value.length; const cursor = inputRef?.getCaretOffset() ?? value.length;
const token = findMentionToken(value, cursor); const token = findMentionToken(value, cursor);
if (!token) return; if (!token) return;
const built = buildMentionInsertion(entry, value, token); const built = buildMentionInsertion(entry, value, token);
if (!built) return; if (!built) return;
// Pin the post-insertion caret BEFORE the swap effect runs; // Pin the post-insertion caret BEFORE the swap effect runs;
@@ -504,6 +519,7 @@
async function handleMicClick() { async function handleMicClick() {
if (!audioRecorder || !recordingSupported) { if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported'); console.warn('Audio recording not supported');
return; return;
} }
@@ -642,7 +658,7 @@
onFileUpload={handleFileUpload} onFileUpload={handleFileUpload}
onMicClick={handleMicClick} onMicClick={handleMicClick}
{onStop} {onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })} onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined} onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)} onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/> />
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Plus } from '@lucide/svelte'; import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants'; import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
import { Plus, File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte'; import {
ChatFormActionAddMcpServersSubmenu,
ChatFormActionAddReasoningSubmenu,
ChatFormActionAddToolsSubmenu
} from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { buttonVariants } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils'; import { cn } from '$lib/components/ui/utils';
import { import {
ATTACHMENT_FILE_ITEMS, ATTACHMENT_FILE_ITEMS,
ATTACHMENT_TOOLTIP_TEXT, ATTACHMENT_TOOLTIP_TEXT,
TOOLTIP_DELAY_DURATION TOOLTIP_DELAY_DURATION
} from '$lib/constants'; } from '$lib/constants';
import { import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
ChatFormActionAddToolsSubmenu,
ChatFormActionAddMcpServersSubmenu,
ChatFormActionAddReasoningSubmenu
} from '$lib/components/app';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte'; import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props { interface Props {
@@ -36,15 +36,15 @@
class: className = '', class: className = '',
disabled = false, disabled = false,
hasAudioModality = false, hasAudioModality = false,
hasVideoModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false, hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false, hasMcpResourcesSupport = false,
hasVideoModality = false,
hasVisionModality = false,
onFileUpload, onFileUpload,
onSystemPromptClick,
onMcpPromptClick, onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick, onMcpSettingsClick,
onMcpResourcesClick onSystemPromptClick
}: Props = $props(); }: Props = $props();
let dropdownOpen = $state(false); let dropdownOpen = $state(false);
@@ -59,13 +59,13 @@
const attachmentMenu = useAttachmentMenu( const attachmentMenu = useAttachmentMenu(
() => ({ () => ({
hasVisionModality,
hasAudioModality, hasAudioModality,
hasVideoModality,
hasMcpPromptsSupport, hasMcpPromptsSupport,
hasMcpResourcesSupport hasMcpResourcesSupport,
hasVideoModality,
hasVisionModality
}), }),
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }), () => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
() => { () => {
dropdownOpen = false; dropdownOpen = false;
} }
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { Plus, Settings } from '@lucide/svelte';
import { Settings, Plus } from '@lucide/svelte'; import { goto } from '$app/navigation';
import { Switch } from '$lib/components/ui/switch'; import { DropdownMenuSearchable, McpLogo, McpServerIdentity } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { McpLogo, DropdownMenuSearchable, McpServerIdentity } from '$lib/components/app'; import { Switch } from '$lib/components/ui/switch';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ROUTES } from '$lib/constants/routes';
import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types'; import type { MCPServerSettingsEntry } from '$lib/types';
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/constants/routes';
interface Props { interface Props {
onMcpSettingsClick?: () => void; onMcpSettingsClick?: () => void;
@@ -24,10 +24,13 @@
let hasMcpServers = $derived(mcpServers.length > 0); let hasMcpServers = $derived(mcpServers.length > 0);
let filteredMcpServers = $derived.by(() => { let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim(); const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers; if (!query) return mcpServers;
return mcpServers.filter((s) => { return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase(); const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase(); const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query); return name.includes(query) || url.includes(query);
}); });
}); });
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte'; import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
const reasoning = useReasoningMenu(); const reasoning = useReasoningMenu();
@@ -1,30 +1,30 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { File, FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Sheet from '$lib/components/ui/sheet';
import * as Collapsible from '$lib/components/ui/collapsible';
import { File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { McpLogo } from '$lib/components/app';
import { import {
PencilRuler, Check,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Lightbulb, Lightbulb,
LightbulbOff, LightbulbOff,
Check PencilRuler
} from '@lucide/svelte'; } from '@lucide/svelte';
import { McpLogo } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as Sheet from '$lib/components/ui/sheet';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { HealthCheckStatus } from '$lib/enums'; import { HealthCheckStatus } from '$lib/enums';
import { AttachmentAction } from '$lib/enums/attachment.enums'; import { AttachmentAction } from '$lib/enums/attachment.enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { Snippet } from 'svelte';
interface Props { interface Props {
class?: string; class?: string;
@@ -45,14 +45,14 @@
class: className = '', class: className = '',
disabled = false, disabled = false,
hasAudioModality = false, hasAudioModality = false,
hasVisionModality = false,
hasVideoModality = false,
hasMcpPromptsSupport = false, hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false, hasMcpResourcesSupport = false,
hasVideoModality = false,
hasVisionModality = false,
onFileUpload, onFileUpload,
onSystemPromptClick,
onMcpPromptClick, onMcpPromptClick,
onMcpResourcesClick, onMcpResourcesClick,
onSystemPromptClick,
trigger trigger
}: Props = $props(); }: Props = $props();
@@ -64,13 +64,13 @@
const attachmentMenu = useAttachmentMenu( const attachmentMenu = useAttachmentMenu(
() => ({ () => ({
hasVisionModality,
hasAudioModality, hasAudioModality,
hasVideoModality,
hasMcpPromptsSupport, hasMcpPromptsSupport,
hasMcpResourcesSupport hasMcpResourcesSupport,
hasVideoModality,
hasVisionModality
}), }),
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }), () => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
() => { () => {
sheetOpen = false; sheetOpen = false;
} }
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { Check, ChevronDown, ChevronRight, Info, Loader2, PencilRuler } from '@lucide/svelte';
import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible'; import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { toolsStore } from '$lib/stores/tools.svelte';
import { CLI_FLAGS } from '$lib/constants'; import { CLI_FLAGS } from '$lib/constants';
import { mcpStore } from '$lib/stores/mcp.svelte'; import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte'; import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
const toolsPanel = useToolsPanel(); const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0); const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { isMobile } from '$lib/stores/viewport.svelte'; import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte'; import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte'; import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte'; import { isMobile } from '$lib/stores/viewport.svelte';
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
@@ -21,9 +21,9 @@
let { let {
disabled = false, disabled = false,
hasAudioModality = false, hasAudioModality = false,
hasVideoModality = false,
hasMcpPromptsSupport = false, hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false, hasMcpResourcesSupport = false,
hasVideoModality = false,
hasVisionModality = false, hasVisionModality = false,
onFileUpload, onFileUpload,
onMcpPromptClick, onMcpPromptClick,
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import { import {
modelsStore,
modelOptions, modelOptions,
modelsStore,
selectedModelId, selectedModelId,
selectedModelName selectedModelName
} from '$lib/stores/models.svelte'; } from '$lib/stores/models.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte'; import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { isMobile } from '$lib/stores/viewport.svelte'; import { isMobile } from '$lib/stores/viewport.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
@@ -27,9 +27,9 @@
disabled = false, disabled = false,
forceForegroundText = false, forceForegroundText = false,
hasAudioModality = $bindable(false), hasAudioModality = $bindable(false),
hasModelSelected = $bindable(false),
hasVideoModality = $bindable(false), hasVideoModality = $bindable(false),
hasVisionModality = $bindable(false), hasVisionModality = $bindable(false),
hasModelSelected = $bindable(false),
isSelectedModelInCache = $bindable(true), isSelectedModelInCache = $bindable(true),
submitTooltip = $bindable(''), submitTooltip = $bindable(''),
useGlobalSelection = false useGlobalSelection = false
@@ -46,6 +46,7 @@
let selectorModel = $derived.by(() => { let selectorModel = $derived.by(() => {
const storeModel = selectedModelName(); const storeModel = selectedModelName();
if (storeModel && storeModel !== conversationModel) { if (storeModel && storeModel !== conversationModel) {
return storeModel; return storeModel;
} }
@@ -66,6 +67,7 @@
modelsStore.selectedModelName = null; modelsStore.selectedModelName = null;
modelsStore.clearSelection(); modelsStore.clearSelection();
} }
lastSyncedConversationModel = conversationModel; lastSyncedConversationModel = conversationModel;
} else if ( } else if (
isRouter && isRouter &&
@@ -76,6 +78,7 @@
) { ) {
lastSyncedConversationModel = null; lastSyncedConversationModel = null;
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model)); const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) modelsStore.selectModelById(first.id); if (first) modelsStore.selectModelById(first.id);
} }
}); });
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Mic, Square } from '@lucide/svelte'; import { Mic, Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props { interface Props {
class?: string; class?: string;
@@ -1,28 +1,28 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { SkipForward, Square } from '@lucide/svelte';
import { Square, SkipForward } from '@lucide/svelte'; import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button'; import { page } from '$app/state';
import { ChatService } from '$lib/services';
import { import {
ChatFormActionsAdd,
ChatFormActionModels, ChatFormActionModels,
ChatFormActionRecord, ChatFormActionRecord,
ChatFormActionsAdd,
ChatFormActionSubmit, ChatFormActionSubmit,
ChatFormContextGauge ChatFormContextGauge
} from '$lib/components/app'; } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ROUTES } from '$lib/constants/routes';
import { FileTypeCategory, MessageRole } from '$lib/enums'; import { FileTypeCategory, MessageRole } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte'; import { ChatService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import { import {
activeProcessingState, activeProcessingState,
isChatStreaming, isChatStreaming,
isLoading as chatIsLoading isLoading as chatIsLoading
} from '$lib/stores/chat.svelte'; } from '$lib/stores/chat.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { config } from '$lib/stores/settings.svelte';
import { getFileTypeCategory } from '$lib/utils'; import { getFileTypeCategory } from '$lib/utils';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ROUTES } from '$lib/constants/routes';
interface Props { interface Props {
canSend?: boolean; canSend?: boolean;
@@ -51,15 +51,15 @@
isLoading = false, isLoading = false,
isReasoning = false, isReasoning = false,
isRecording = false, isRecording = false,
showAddButton = true,
showModelSelector = true,
uploadedFiles = [],
onFileUpload, onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMicClick, onMicClick,
onStop, onStop,
onSystemPromptClick, onSystemPromptClick,
onMcpPromptClick, showAddButton = true,
onMcpResourcesClick showModelSelector = true,
uploadedFiles = []
}: Props = $props(); }: Props = $props();
let currentConfig = $derived(config()); let currentConfig = $derived(config());
@@ -105,29 +105,39 @@
if (!page.params.id) return false; if (!page.params.id) return false;
const messages = activeMessages() as DatabaseMessage[]; const messages = activeMessages() as DatabaseMessage[];
let totalHistoricalTokens = 0; let totalHistoricalTokens = 0;
for (const m of messages) { for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT) continue; if (m.role !== MessageRole.ASSISTANT) continue;
const timings = m.timings; const timings = m.timings;
if (!timings) continue; if (!timings) continue;
const agenticLlm = timings.agentic?.llm; const agenticLlm = timings.agentic?.llm;
if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) { if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) {
totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0); totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0);
} else { } else {
totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0); totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0);
} }
} }
if (totalHistoricalTokens > 0) return true; if (totalHistoricalTokens > 0) return true;
if (!chatIsLoading() && !isChatStreaming()) return false; if (!chatIsLoading() && !isChatStreaming()) return false;
const processingState = activeProcessingState(); const processingState = activeProcessingState();
if (!processingState) return false; if (!processingState) return false;
const livePromptTokens = Math.max( const livePromptTokens = Math.max(
processingState.promptTokens ?? 0, processingState.promptTokens ?? 0,
processingState.promptProgress?.processed ?? 0 processingState.promptProgress?.processed ?? 0
); );
const liveOutputTokens = processingState.outputTokensUsed ?? 0; const liveOutputTokens = processingState.outputTokensUsed ?? 0;
return livePromptTokens > 0 || liveOutputTokens > 0; return livePromptTokens > 0 || liveOutputTokens > 0;
}); });
</script> </script>
@@ -1,11 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount, untrack } from 'svelte';
import { mode } from 'mode-watcher';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { isMobile } from '$lib/stores/viewport.svelte';
import { ColorMode } from '$lib/enums';
import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants'; import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants';
import { ColorMode } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
import { import {
badgeAwareWordJump, badgeAwareWordJump,
buildFragment, buildFragment,
@@ -19,10 +16,13 @@
SourceHistory, SourceHistory,
stripBlockBoundaryLineBreaks, stripBlockBoundaryLineBreaks,
syncCodeBlockHatches, syncCodeBlockHatches,
tokenizeContent, textOffsetToRange,
textOffsetToRange tokenizeContent
} from '$lib/utils'; } from '$lib/utils';
import type { ContentToken, SourceHistoryEntry } from '$lib/utils'; import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import { mode } from 'mode-watcher';
import { onDestroy, onMount, untrack } from 'svelte';
interface Props { interface Props {
class?: string; class?: string;
@@ -57,7 +57,9 @@
// serialized source, not the DOM shape. // serialized source, not the DOM shape.
function syncEmptyState(serialized?: string) { function syncEmptyState(serialized?: string) {
if (!rootElement) return; if (!rootElement) return;
const source = serialized ?? serializeContent(rootElement); const source = serialized ?? serializeContent(rootElement);
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false'; rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
} }
@@ -93,23 +95,24 @@
*/ */
function highlightCodeBlockElement(el: HTMLElement): boolean { function highlightCodeBlockElement(el: HTMLElement): boolean {
const segment = el.textContent ?? ''; const segment = el.textContent ?? '';
if (highlightedSegments.get(el) === segment) return false; if (highlightedSegments.get(el) === segment) return false;
const open = CODE_BLOCK_OPEN_RE.exec(segment); const open = CODE_BLOCK_OPEN_RE.exec(segment);
if (!open) return false; if (!open) return false;
const prefix = open[0]; const prefix = open[0];
const language = open[1].trim().split(/\s+/)[0] ?? ''; const language = open[1].trim().split(/\s+/)[0] ?? '';
const content = segment.slice(prefix.length, -3); const content = segment.slice(prefix.length, -3);
const leading = content.match(TRIM_LEADING_PADDING_REGEX)?.[0] ?? ''; const leading = content.match(TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
const trailing = content.match(TRIM_TRAILING_PADDING_REGEX)?.[0] ?? ''; const trailing = content.match(TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
const core = content.slice(leading.length, content.length - trailing.length); const core = content.slice(leading.length, content.length - trailing.length);
// autoDetect off: re-guessing the language on every keystroke // autoDetect off: re-guessing the language on every keystroke
// costs ~38ms a call and flickers while typing // costs ~38ms a call and flickers while typing
const html = core ? highlightCode(core, language || 'text', false) : ''; const html = core ? highlightCode(core, language || 'text', false) : '';
const tpl = document.createElement('template'); const tpl = document.createElement('template');
tpl.innerHTML = html; tpl.innerHTML = html;
el.replaceChildren( el.replaceChildren(
@@ -118,6 +121,7 @@
document.createTextNode(trailing + '```') document.createTextNode(trailing + '```')
); );
highlightedSegments.set(el, segment); highlightedSegments.set(el, segment);
return true; return true;
} }
@@ -136,9 +140,11 @@
if (!rootElement) return; if (!rootElement) return;
const range = safeRange(); const range = safeRange();
if (!range) return; if (!range) return;
let node: Node | null = range.startContainer; let node: Node | null = range.startContainer;
if (node === rootElement) { if (node === rootElement) {
node = rootElement.childNodes[range.startOffset - 1] ?? null; node = rootElement.childNodes[range.startOffset - 1] ?? null;
} }
@@ -146,11 +152,14 @@
while (node && node !== rootElement) { while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') { if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const caret = rangeToTextOffset(rootElement, range); const caret = rangeToTextOffset(rootElement, range);
if (highlightCodeBlockElement(node)) { if (highlightCodeBlockElement(node)) {
restoreCaret(caret); restoreCaret(caret);
} }
return; return;
} }
node = node.parentNode; node = node.parentNode;
} }
} }
@@ -182,6 +191,7 @@
document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove()); document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
const style = document.createElement('style'); const style = document.createElement('style');
style.setAttribute('data-highlight-theme-preview', 'true'); style.setAttribute('data-highlight-theme-preview', 'true');
style.textContent = isDark ? githubDarkCss : githubLightCss; style.textContent = isDark ? githubDarkCss : githubLightCss;
@@ -196,6 +206,7 @@
if (!rootElement) return null; if (!rootElement) return null;
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null; if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
@@ -212,6 +223,7 @@
const target = textOffsetToRange(rootElement, offset); const target = textOffsetToRange(rootElement, offset);
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection) return; if (!selection) return;
if (extend && selection.anchorNode) { if (extend && selection.anchorNode) {
@@ -221,6 +233,7 @@
target.startContainer, target.startContainer,
target.startOffset target.startOffset
); );
return; return;
} }
@@ -230,14 +243,16 @@
function resizeHeight() { function resizeHeight() {
if (!rootElement) return; if (!rootElement) return;
rootElement.style.height = 'auto'; rootElement.style.height = 'auto';
rootElement.style.height = `${rootElement.scrollHeight}px`; rootElement.style.height = `${rootElement.scrollHeight}px`;
} }
function recordHistory(newGroup: boolean) { function recordHistory(newGroup: boolean) {
if (!rootElement) return; if (!rootElement) return;
history.push( history.push(
{ value: lastEmittedValue, caret: rangeToTextOffset(rootElement, safeRange()) }, { caret: rangeToTextOffset(rootElement, safeRange()), value: lastEmittedValue },
Date.now(), Date.now(),
newGroup newGroup
); );
@@ -262,10 +277,12 @@
// lands on the line directly below the block. // lands on the line directly below the block.
if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') { if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
const caret = rangeToTextOffset(rootElement, safeRange()); const caret = rangeToTextOffset(rootElement, safeRange());
if (stripBlockBoundaryLineBreaks(rootElement)) { if (stripBlockBoundaryLineBreaks(rootElement)) {
restoreCaret(caret); restoreCaret(caret);
} else { } else {
const source = serializeContent(rootElement); const source = serializeContent(rootElement);
let end = caret; let end = caret;
// the caret must end up after the inserted \n; some browsers // the caret must end up after the inserted \n; some browsers
@@ -284,7 +301,9 @@
// \n doubles as a block's separator line (source ends with // \n doubles as a block's separator line (source ends with
// \n\n) or sits inside a block element. // \n\n) or sits inside a block element.
let last = rootElement.lastChild; let last = rootElement.lastChild;
while (last && last.nodeName === 'BR') last = last.previousSibling; while (last && last.nodeName === 'BR') last = last.previousSibling;
if ( if (
end === source.length && end === source.length &&
source.endsWith('\n') && source.endsWith('\n') &&
@@ -302,7 +321,9 @@
syncCodeBlockHatches(rootElement); syncCodeBlockHatches(rootElement);
const serialized = serializeContent(rootElement); const serialized = serializeContent(rootElement);
syncEmptyState(serialized); syncEmptyState(serialized);
if (serialized === lastEmittedValue) return; if (serialized === lastEmittedValue) return;
// Plain typing/deletes coalesce per time window; structural edits // Plain typing/deletes coalesce per time window; structural edits
@@ -316,6 +337,7 @@
// completed or broken) - the browser-owned text nodes cannot // completed or broken) - the browser-owned text nodes cannot
// restyle themselves across element boundaries. // restyle themselves across element boundaries.
const tokens = tokenizeContent(serialized); const tokens = tokenizeContent(serialized);
if (!domMatchesTokens(rootElement, tokens)) { if (!domMatchesTokens(rootElement, tokens)) {
renderTokens(tokens); renderTokens(tokens);
@@ -324,6 +346,7 @@
// block element and the rebuild splits it back out, which // block element and the rebuild splits it back out, which
// synthesizes the separator newline) - keep value in sync. // synthesizes the separator newline) - keep value in sync.
const reserialized = serializeContent(rootElement); const reserialized = serializeContent(rootElement);
if (reserialized !== serialized) { if (reserialized !== serialized) {
lastEmittedValue = reserialized; lastEmittedValue = reserialized;
value = reserialized; value = reserialized;
@@ -361,6 +384,7 @@
if (!rootElement) return; if (!rootElement) return;
const range = safeRange(); const range = safeRange();
if (!range) return; if (!range) return;
if (!range.collapsed) { if (!range.collapsed) {
@@ -374,16 +398,22 @@
// a break at the very end of a code block exits the block (the // a break at the very end of a code block exits the block (the
// new line belongs below it, not inside) // new line belongs below it, not inside)
let exitBlock: HTMLElement | null = null; let exitBlock: HTMLElement | null = null;
if (container.nodeType === Node.TEXT_NODE) { if (container.nodeType === Node.TEXT_NODE) {
let node: Node | null = container.parentNode; let node: Node | null = container.parentNode;
while (node && node !== rootElement) { while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') { if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const tail = document.createRange(); const tail = document.createRange();
tail.setStart(container, offset); tail.setStart(container, offset);
tail.setEnd(node, node.childNodes.length); tail.setEnd(node, node.childNodes.length);
if (tail.toString().length === 0) exitBlock = node; if (tail.toString().length === 0) exitBlock = node;
break; break;
} }
node = node.parentNode; node = node.parentNode;
} }
} }
@@ -392,6 +422,7 @@
exitBlock.after(nl); exitBlock.after(nl);
} else if (container.nodeType === Node.TEXT_NODE) { } else if (container.nodeType === Node.TEXT_NODE) {
const text = container as Text; const text = container as Text;
if (offset === 0) { if (offset === 0) {
text.before(nl); text.before(nl);
} else if (offset === text.length) { } else if (offset === text.length) {
@@ -405,6 +436,7 @@
const selection = window.getSelection(); const selection = window.getSelection();
const after = document.createRange(); const after = document.createRange();
after.setStartAfter(nl); after.setStartAfter(nl);
after.collapse(true); after.collapse(true);
selection?.removeAllRanges(); selection?.removeAllRanges();
@@ -428,9 +460,11 @@
if (rootElement.firstChild?.nodeName === 'BR') return false; if (rootElement.firstChild?.nodeName === 'BR') return false;
const first = rootElement.firstChild; const first = rootElement.firstChild;
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false; if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
const range = safeRange(); const range = safeRange();
if (!range || !range.collapsed) return false; if (!range || !range.collapsed) return false;
// the caret must sit inside the block: on its very first // the caret must sit inside the block: on its very first
@@ -439,16 +473,19 @@
if (!first.contains(range.startContainer)) return false; if (!first.contains(range.startContainer)) return false;
const caret = rangeToTextOffset(rootElement, range); const caret = rangeToTextOffset(rootElement, range);
if (key === 'ArrowLeft') { if (key === 'ArrowLeft') {
if (caret !== 0) return false; if (caret !== 0) return false;
} else { } else {
const firstLineEnd = (first.textContent ?? '').indexOf('\n'); const firstLineEnd = (first.textContent ?? '').indexOf('\n');
if (firstLineEnd !== -1 && caret > firstLineEnd) return false; if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
} }
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children // eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
rootElement.prepend(document.createElement('br')); rootElement.prepend(document.createElement('br'));
restoreCaret(0, extend); restoreCaret(0, extend);
return true; return true;
} }
@@ -464,14 +501,17 @@
if (!rootElement) return; if (!rootElement) return;
const first = rootElement.firstChild; const first = rootElement.firstChild;
if (first?.nodeName !== 'BR') return; if (first?.nodeName !== 'BR') return;
const second = first.nextSibling; const second = first.nextSibling;
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return; if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
const range = safeRange(); const range = safeRange();
const onHatch = const onHatch =
range !== null && range.startContainer === rootElement && range.startOffset === 0; range !== null && range.startContainer === rootElement && range.startOffset === 0;
if (!onHatch) { if (!onHatch) {
first.remove(); first.remove();
} }
@@ -491,6 +531,7 @@
*/ */
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
const mod = event.ctrlKey || event.metaKey; const mod = event.ctrlKey || event.metaKey;
if (mod && !event.altKey && !isComposing && rootElement) { if (mod && !event.altKey && !isComposing && rootElement) {
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey; const isUndo = key === 'z' && !event.shiftKey;
@@ -499,11 +540,13 @@
if (isUndo || isRedo) { if (isUndo || isRedo) {
event.preventDefault(); event.preventDefault();
const current = { const current = {
value: lastEmittedValue, caret: rangeToTextOffset(rootElement, safeRange()),
caret: rangeToTextOffset(rootElement, safeRange()) value: lastEmittedValue
}; };
const entry = isUndo ? history.undo(current) : history.redo(current); const entry = isUndo ? history.undo(current) : history.redo(current);
if (entry) applyHistoryEntry(entry); if (entry) applyHistoryEntry(entry);
return; return;
} }
} }
@@ -524,6 +567,7 @@
// stuck on the old line (see insertLineBreak). // stuck on the old line (see insertLineBreak).
event.preventDefault(); event.preventDefault();
insertLineBreak(); insertLineBreak();
return; return;
} }
@@ -543,6 +587,7 @@
// re-tokenize/re-highlight follows. // re-tokenize/re-highlight follows.
event.preventDefault(); event.preventDefault();
document.execCommand('insertLineBreak'); document.execCommand('insertLineBreak');
return; return;
} }
@@ -555,6 +600,7 @@
) { ) {
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) { if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
event.preventDefault(); event.preventDefault();
return; return;
} }
} }
@@ -574,6 +620,7 @@
if (target !== null) { if (target !== null) {
event.preventDefault(); event.preventDefault();
restoreCaret(target, event.shiftKey); restoreCaret(target, event.shiftKey);
return; return;
} }
} }
@@ -586,6 +633,7 @@
// change as our own and does not re-render. // change as our own and does not re-render.
function applyHistoryEntry(entry: SourceHistoryEntry) { function applyHistoryEntry(entry: SourceHistoryEntry) {
if (!rootElement) return; if (!rootElement) return;
renderTokens(tokenizeContent(entry.value)); renderTokens(tokenizeContent(entry.value));
lastEmittedValue = entry.value; lastEmittedValue = entry.value;
value = entry.value; value = entry.value;
@@ -602,6 +650,7 @@
*/ */
function handlePasteEvent(event: ClipboardEvent) { function handlePasteEvent(event: ClipboardEvent) {
const pasted = event.clipboardData?.getData('text/plain'); const pasted = event.clipboardData?.getData('text/plain');
if (pasted && pasted.length > 0) { if (pasted && pasted.length > 0) {
event.preventDefault(); event.preventDefault();
@@ -609,6 +658,7 @@
// element-boundary carets (e.g. right before a badge) Chromium's // element-boundary carets (e.g. right before a badge) Chromium's
// insertText can drop the preceding text node's trailing whitespace. // insertText can drop the preceding text node's trailing whitespace.
const range = safeRange(); const range = safeRange();
if (rootElement && range && range.collapsed) { if (rootElement && range && range.collapsed) {
restoreCaret(rangeToTextOffset(rootElement, range)); restoreCaret(rangeToTextOffset(rootElement, range));
} }
@@ -621,6 +671,7 @@
// consumes the event (files, quoted prompts, long text). // consumes the event (files, quoted prompts, long text).
function handlePaste(event: ClipboardEvent) { function handlePaste(event: ClipboardEvent) {
onPaste?.(event); onPaste?.(event);
if (!event.defaultPrevented) { if (!event.defaultPrevented) {
handlePasteEvent(event); handlePasteEvent(event);
} }
@@ -634,20 +685,23 @@
if (!rootElement) return null; if (!rootElement) return null;
const range = safeRange(); const range = safeRange();
if (!range || range.collapsed) return null; if (!range || range.collapsed) return null;
const startRange = range.cloneRange(); const startRange = range.cloneRange();
startRange.collapse(true); startRange.collapse(true);
const source = serializeContent(rootElement); const source = serializeContent(rootElement);
const start = rangeToTextOffset(rootElement, startRange); const start = rangeToTextOffset(rootElement, startRange);
const end = rangeToTextOffset(rootElement, range); const end = rangeToTextOffset(rootElement, range);
return { text: source.slice(start, end), range }; return { range, text: source.slice(start, end) };
} }
function handleCopy(event: ClipboardEvent) { function handleCopy(event: ClipboardEvent) {
const slice = selectionSourceSlice(); const slice = selectionSourceSlice();
if (!slice) return; if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text); event.clipboardData?.setData('text/plain', slice.text);
@@ -656,6 +710,7 @@
function handleCut(event: ClipboardEvent) { function handleCut(event: ClipboardEvent) {
const slice = selectionSourceSlice(); const slice = selectionSourceSlice();
if (!slice) return; if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text); event.clipboardData?.setData('text/plain', slice.text);
@@ -675,6 +730,7 @@
resizeHeight(); resizeHeight();
syncEmptyState(); syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange); document.addEventListener('selectionchange', handleSelectionChange);
if (!isMobile.current) { if (!isMobile.current) {
rootElement?.focus({ preventScroll: true }); rootElement?.focus({ preventScroll: true });
} }
@@ -689,6 +745,7 @@
// browser already owns the right shape. // browser already owns the right shape.
$effect(() => { $effect(() => {
const incoming = value ?? ''; const incoming = value ?? '';
if (incoming === lastEmittedValue) return; if (incoming === lastEmittedValue) return;
recordHistory(true); // external edit (mention insert, clear, ...): own undo step recordHistory(true); // external edit (mention insert, clear, ...): own undo step
@@ -702,6 +759,7 @@
export function getCaretOffset(): number { export function getCaretOffset(): number {
if (!rootElement) return 0; if (!rootElement) return 0;
return rangeToTextOffset(rootElement, safeRange()); return rangeToTextOffset(rootElement, safeRange());
} }
@@ -710,11 +768,13 @@
if (rootElement && rootElement !== document.activeElement) { if (rootElement && rootElement !== document.activeElement) {
rootElement.focus({ preventScroll: true }); rootElement.focus({ preventScroll: true });
} }
restoreCaret(offset); restoreCaret(offset);
} }
export function focus() { export function focus() {
if (isMobile.current) return; if (isMobile.current) return;
rootElement?.focus({ preventScroll: true }); rootElement?.focus({ preventScroll: true });
} }
@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import ContextGaugeDial from './ContextGaugeDial.svelte'; import ContextGaugeDial from './ContextGaugeDial.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import { import {
gaugeTriggerClick, gaugeTriggerClick,
gaugeTriggerEnter, gaugeTriggerEnter,
@@ -11,22 +9,28 @@
gaugeTriggerLeave, gaugeTriggerLeave,
gaugeTriggerPointerDown gaugeTriggerPointerDown
} from '$lib/stores/context-gauge-popup.svelte'; } from '$lib/stores/context-gauge-popup.svelte';
import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
import { untrack } from 'svelte';
const gauge = useContextGauge(); const gauge = useContextGauge();
$effect(() => { $effect(() => {
const conv = activeConversation(); const conv = activeConversation();
untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null)); untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null));
}); });
$effect(() => { $effect(() => {
const conv = activeConversation(); const conv = activeConversation();
const messages = activeMessages() as DatabaseMessage[]; const messages = activeMessages() as DatabaseMessage[];
if (!conv) return; if (!conv) return;
if (isLoading() || isChatStreaming()) return; if (isLoading() || isChatStreaming()) return;
if (messages.length === 0) { if (messages.length === 0) {
untrack(() => chatStore.clearProcessingState(conv.id)); untrack(() => chatStore.clearProcessingState(conv.id));
return; return;
} }
@@ -5,7 +5,7 @@
subtitle?: string; subtitle?: string;
} }
let { label, value, subtitle }: Props = $props(); let { label, subtitle, value }: Props = $props();
</script> </script>
<div class="grid gap-1.5"> <div class="grid gap-1.5">
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
import { ChevronDown } from '@lucide/svelte'; import { ChevronDown } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible'; import * as Collapsible from '$lib/components/ui/collapsible';
import { STATS_UNITS } from '$lib/constants'; import { STATS_UNITS } from '$lib/constants';
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
interface Props { interface Props {
currentRead: number; currentRead: number;
@@ -18,15 +18,15 @@
} }
let { let {
currentRead,
currentFresh,
currentCache,
currentOutput,
kvTotal,
cumulativeRead,
cumulativeOutput,
cumulativeCacheTotal,
averageTokensPerSecond, averageTokensPerSecond,
cumulativeCacheTotal,
cumulativeOutput,
cumulativeRead,
currentCache,
currentFresh,
currentOutput,
currentRead,
kvTotal,
transientDetails transientDetails
}: Props = $props(); }: Props = $props();
@@ -8,7 +8,7 @@
size?: 'sm' | 'md'; size?: 'sm' | 'md';
} }
let { percent, level, size = 'sm' }: Props = $props(); let { level, percent, size = 'sm' }: Props = $props();
const RADIUS = 11; const RADIUS = 11;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
@@ -8,7 +8,7 @@
onLoad: () => void; onLoad: () => void;
} }
let { modelId, isLoading, onLoad }: Props = $props(); let { isLoading, modelId, onLoad }: Props = $props();
</script> </script>
{#if modelId !== null && !isLoading} {#if modelId !== null && !isLoading}
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { formatParameters } from '$lib/utils/formatters'; import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import ContextGaugeDetails from './ContextGaugeDetails.svelte'; import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte'; import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge'; import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { import {
gaugePopup,
gaugeCardEnter, gaugeCardEnter,
gaugeCardLeave, gaugeCardLeave,
gaugePopup,
gaugePopupClose gaugePopupClose
} from '$lib/stores/context-gauge-popup.svelte'; } from '$lib/stores/context-gauge-popup.svelte';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge(); const gauge = useContextGauge();
@@ -30,13 +30,18 @@
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
const target = event.target; const target = event.target;
if (!(target instanceof Node)) return; if (!(target instanceof Node)) return;
if (cardEl?.contains(target)) return; if (cardEl?.contains(target)) return;
if (target instanceof Element && target.closest('[data-context-gauge-trigger]')) return; if (target instanceof Element && target.closest('[data-context-gauge-trigger]')) return;
gaugePopupClose(); gaugePopupClose();
}; };
document.addEventListener('pointerdown', onPointerDown, true); document.addEventListener('pointerdown', onPointerDown, true);
return () => document.removeEventListener('pointerdown', onPointerDown, true); return () => document.removeEventListener('pointerdown', onPointerDown, true);
}); });
@@ -5,8 +5,11 @@ const CRITICAL_THRESHOLD = 95;
export function colorLevelFromPercent(percent: number | null): ColorLevel { export function colorLevelFromPercent(percent: number | null): ColorLevel {
if (percent === null) return 'neutral'; if (percent === null) return 'neutral';
if (percent >= CRITICAL_THRESHOLD) return 'critical'; if (percent >= CRITICAL_THRESHOLD) return 'critical';
if (percent >= WARNING_THRESHOLD) return 'warning'; if (percent >= WARNING_THRESHOLD) return 'warning';
return 'ok'; return 'ok';
} }
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
mcpResourceAttachments,
mcpHasResourceAttachments
} from '$lib/stores/mcp-resources.svelte';
import { import {
ChatAttachmentsListItemMcpResource, ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel HorizontalScrollCarousel
} from '$lib/components/app'; } from '$lib/components/app';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
mcpHasResourceAttachments,
mcpResourceAttachments
} from '$lib/stores/mcp-resources.svelte';
interface Props { interface Props {
class?: string; class?: string;
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { FolderOpen, Sparkles } from '@lucide/svelte'; import { FolderOpen, Sparkles } from '@lucide/svelte';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { ChatFormCommandAction } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import { import {
ChatFormPickerList, ChatFormPickerList,
ChatFormPickerListItem, ChatFormPickerListItem,
ChatFormPickerPopover ChatFormPickerPopover
} from '$lib/components/app/chat'; } from '$lib/components/app/chat';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { ChatFormCommandAction } from '$lib/enums';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { ChatFormCommand } from '$lib/types';
/** /**
* Slash-command picker; `query` (typed after `/`) filters the commands. * Slash-command picker; `query` (typed after `/`) filters the commands.
@@ -24,12 +24,12 @@
onSelect: (command: ChatFormCommand) => void; onSelect: (command: ChatFormCommand) => void;
} }
let { class: className = '', isOpen, query, commands, onClose, onSelect }: Props = $props(); let { class: className = '', commands, isOpen, onClose, onSelect, query }: Props = $props();
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = { const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
[ChatFormCommandAction.PROMPT]: Sparkles,
[ChatFormCommandAction.CWD]: FolderOpen, [ChatFormCommandAction.CWD]: FolderOpen,
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON [ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON,
[ChatFormCommandAction.PROMPT]: Sparkles
}; };
const trimmedQuery = $derived((query ?? '').trim().toLowerCase()); const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
@@ -51,20 +51,24 @@
function stepEnabled(from: number, dir: number): number { function stepEnabled(from: number, dir: number): number {
const n = filteredCommands.length; const n = filteredCommands.length;
if (n === 0) return -1; if (n === 0) return -1;
for (let i = 1; i <= n; i++) { for (let i = 1; i <= n; i++) {
const idx = (from + dir * i + n) % n; const idx = (from + dir * i + n) % n;
if (!filteredCommands[idx].disabled) return idx; if (!filteredCommands[idx].disabled) return idx;
} }
return -1; return -1;
} }
const nav = usePickerNavigation({ const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => filteredCommands.length, count: () => filteredCommands.length,
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir)), isOpen: () => isOpen,
onClose: () => onClose(), onClose: () => onClose(),
onSelect: (index) => handleSelect(filteredCommands[index]) onSelect: (index) => handleSelect(filteredCommands[index]),
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir))
}); });
$effect(() => { $effect(() => {
@@ -76,8 +80,10 @@
$effect(() => { $effect(() => {
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) { if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
nav.reset(firstEnabledIndex()); nav.reset(firstEnabledIndex());
return; return;
} }
if (filteredCommands[nav.hoveredIndex].disabled) { if (filteredCommands[nav.hoveredIndex].disabled) {
nav.reset(firstEnabledIndex()); nav.reset(firstEnabledIndex());
} }
@@ -85,6 +91,7 @@
function handleSelect(command: ChatFormCommand) { function handleSelect(command: ChatFormCommand) {
if (command.disabled) return; if (command.disabled) return;
onSelect(command); onSelect(command);
onClose(); onClose();
} }
@@ -1,22 +1,22 @@
<script lang="ts"> <script lang="ts">
import { File, Folder } from '@lucide/svelte'; import { File, Folder } from '@lucide/svelte';
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils'; import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
import { toolsStore } from '$lib/stores/tools.svelte'; import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import { config } from '$lib/stores/settings.svelte';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { FileMentionEntry } from '$lib/types';
import { import {
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH, FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
HOME_TILDE, HOME_TILDE,
SEARCH_DEBOUNCE_MS SEARCH_DEBOUNCE_MS
} from '$lib/constants'; } from '$lib/constants';
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { config } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { FileMentionEntry } from '$lib/types';
import { abbreviateHome, type GlobEntryResult, runGlobSearchWithChildren } from '$lib/utils';
/** /**
* Floating file/folder mention picker. The chat input is the search * Floating file/folder mention picker. The chat input is the search
@@ -38,18 +38,18 @@
let { let {
class: className = '', class: className = '',
isOpen,
query,
customAnchor = null, customAnchor = null,
scopePath = null, isOpen,
onClose, onClose,
onOpened,
onSelect, onSelect,
onOpened query,
scopePath = null
}: Props = $props(); }: Props = $props();
const nav = usePickerNavigation({ const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => displayedItems.length, count: () => displayedItems.length,
isOpen: () => isOpen,
onClose: () => onClose(), onClose: () => onClose(),
onSelect: (index) => handleSelect(displayedItems[index]) onSelect: (index) => handleSelect(displayedItems[index])
}); });
@@ -69,6 +69,7 @@
// would otherwise reach the server as max_depth 0 = unlimited. // would otherwise reach the server as max_depth 0 = unlimited.
const searchDepth = $derived.by(() => { const searchDepth = $derived.by(() => {
const n = Number(config().mentionSearchMaxDepth); const n = Number(config().mentionSearchMaxDepth);
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH; return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
}); });
@@ -78,8 +79,8 @@
const MENTION_SEARCH_LIMIT = 50; const MENTION_SEARCH_LIMIT = 50;
const search = useDebouncedSearch({ const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen && fileSearchEnabled, canRun: () => isOpen && fileSearchEnabled,
debounceMs: SEARCH_DEBOUNCE_MS,
getQuery: () => trimmedQuery, getQuery: () => trimmedQuery,
run: async (query, signal, isCurrent) => { run: async (query, signal, isCurrent) => {
try { try {
@@ -91,23 +92,29 @@
searchDepth, searchDepth,
MENTION_SEARCH_LIMIT, MENTION_SEARCH_LIMIT,
signal, signal,
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true } { descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
); );
if (!isCurrent()) return; if (!isCurrent()) return;
if (res.error) { if (res.error) {
searchResults = []; searchResults = [];
searchError = res.error; searchError = res.error;
return; return;
} }
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({ const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
path: e.path,
name: e.name, name: e.name,
path: e.path,
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
}); });
searchResults = res.entries.map(toEntry); searchResults = res.entries.map(toEntry);
searchError = null; searchError = null;
} catch (err) { } catch (err) {
if (!isCurrent() || signal.aborted) return; if (!isCurrent() || signal.aborted) return;
searchResults = []; searchResults = [];
searchError = err instanceof Error ? err.message : String(err); searchError = err instanceof Error ? err.message : String(err);
} }
@@ -121,9 +128,11 @@
if (fileSearchKey === null) { if (fileSearchKey === null) {
return 'File search is unavailable on this server (started without --tools)'; return 'File search is unavailable on this server (started without --tools)';
} }
if (!fileSearchEnabled) { if (!fileSearchEnabled) {
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'; return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
} }
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders'; return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
}); });
@@ -131,6 +140,7 @@
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome(); void toolsStore.resolveServerHome();
}); });
@@ -146,12 +156,15 @@
$effect(() => { $effect(() => {
const q = (query ?? '').trim(); const q = (query ?? '').trim();
if (!isOpen || !q || !fileSearchEnabled) { if (!isOpen || !q || !fileSearchEnabled) {
search.cancel(); search.cancel();
searchResults = []; searchResults = [];
searchError = null; searchError = null;
return; return;
} }
search.setLoading(true); search.setLoading(true);
search.run(q); search.run(q);
}); });
@@ -167,9 +180,11 @@
// Enter-to-submit never fires mid-search. // Enter-to-submit never fires mid-search.
if (isOpen && event.key === KeyboardKey.ENTER) { if (isOpen && event.key === KeyboardKey.ENTER) {
event.preventDefault(); event.preventDefault();
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) { if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
handleSelect(displayedItems[nav.hoveredIndex]); handleSelect(displayedItems[nav.hoveredIndex]);
} }
return true; return true;
} }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import type { MCPServerSettingsEntry } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPServerSettingsEntry } from '$lib/types';
import type { Snippet } from 'svelte';
interface Props { interface Props {
server: MCPServerSettingsEntry | undefined; server: MCPServerSettingsEntry | undefined;
@@ -12,7 +12,7 @@
subtitle?: Snippet; subtitle?: Snippet;
} }
let { server, serverLabel, title, description, titleExtra, subtitle }: Props = $props(); let { description, server, serverLabel, subtitle, title, titleExtra }: Props = $props();
let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null); let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null);
</script> </script>
@@ -1,9 +1,9 @@
<script lang="ts" generics="T"> <script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { SearchInput } from '$lib/components/app'; import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte'; import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants'; import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import type { Snippet } from 'svelte';
interface Props { interface Props {
items: T[]; items: T[];
@@ -28,22 +28,22 @@
} }
let { let {
items,
isLoading,
selectedIndex,
searchQuery = $bindable(),
showSearchInput,
searchPlaceholder = 'Search...',
emptyMessage,
autofocus = false, autofocus = false,
inputRef = $bindable(null), emptyMessage,
onSearchClose,
itemKey,
item,
skeleton,
skeletonCount = 6,
footer, footer,
scrollTrigger inputRef = $bindable(null),
isLoading,
item,
itemKey,
items,
onSearchClose,
scrollTrigger,
searchPlaceholder = 'Search...',
searchQuery = $bindable(),
selectedIndex,
showSearchInput,
skeleton,
skeletonCount = 6
}: Props = $props(); }: Props = $props();
let listContainer = $state<HTMLDivElement | null>(null); let listContainer = $state<HTMLDivElement | null>(null);
@@ -55,11 +55,11 @@
// selectedIndex/items.length are untracked so hover and result replacement // selectedIndex/items.length are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger. // never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
useScrollActiveRow({ useScrollActiveRow({
getTrigger: () => scrollTrigger, dataIndex: 'picker',
getContainer: () => listContainer, getContainer: () => listContainer,
getIndex: () => selectedIndex,
getCount: () => items.length, getCount: () => items.length,
dataIndex: 'picker' getIndex: () => selectedIndex,
getTrigger: () => scrollTrigger
}); });
</script> </script>
@@ -12,13 +12,13 @@
} }
let { let {
children,
class: className = '', class: className = '',
isSelected = false,
disabled = false,
onclick,
onmouseenter,
dataIndex, dataIndex,
children disabled = false,
isSelected = false,
onclick,
onmouseenter
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -4,7 +4,7 @@
showBadge?: boolean; showBadge?: boolean;
} }
let { titleWidth = 'w-48', showBadge = false }: Props = $props(); let { showBadge = false, titleWidth = 'w-48' }: Props = $props();
</script> </script>
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2"> <div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import type { Snippet } from 'svelte';
interface Props { interface Props {
class?: string; class?: string;
@@ -12,12 +12,12 @@
} }
let { let {
children,
class: className = '', class: className = '',
isOpen = $bindable(false), isOpen = $bindable(false),
srLabel = 'Open picker',
onClose, onClose,
onKeydown, onKeydown,
children srLabel = 'Open picker'
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { debounce, uuid } from '$lib/utils';
import { KeyboardKey } from '$lib/enums';
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import { import {
ChatFormPickerPopover, ChatFormPickerItemHeader,
ChatFormPickerList, ChatFormPickerList,
ChatFormPickerListItem, ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton, ChatFormPickerListItemSkeleton,
ChatFormPickerPopover,
ChatFormPromptPickerArgumentForm ChatFormPromptPickerArgumentForm
} from '$lib/components/app/chat'; } from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte'; import Badge from '$lib/components/ui/badge/badge.svelte';
import { KeyboardKey } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { debounce, uuid } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
interface Props { interface Props {
class?: string; class?: string;
@@ -32,11 +32,11 @@
let { let {
class: className = '', class: className = '',
isOpen = false, isOpen = false,
searchQuery = '',
onClose, onClose,
onPromptLoadStart,
onPromptLoadComplete, onPromptLoadComplete,
onPromptLoadError onPromptLoadError,
onPromptLoadStart,
searchQuery = ''
}: Props = $props(); }: Props = $props();
let prompts = $state<MCPPromptInfo[]>([]); let prompts = $state<MCPPromptInfo[]>([]);
@@ -89,7 +89,6 @@
try { try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides(); const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides); const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) { if (!initialized) {
@@ -118,6 +117,7 @@
requestAnimationFrame(() => { requestAnimationFrame(() => {
const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement; const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
if (firstInput) { if (firstInput) {
firstInput.focus(); firstInput.focus();
} }
@@ -131,7 +131,6 @@
promptError = null; promptError = null;
const placeholderId = uuid(); const placeholderId = uuid();
const nonEmptyArgs = Object.fromEntries( const nonEmptyArgs = Object.fromEntries(
Object.entries(args).filter(([, value]) => value.trim() !== '') Object.entries(args).filter(([, value]) => value.trim() !== '')
); );
@@ -142,10 +141,12 @@
try { try {
const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args); const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
onPromptLoadComplete?.(placeholderId, result); onPromptLoadComplete?.(placeholderId, result);
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : 'Unknown error executing prompt'; error instanceof Error ? error.message : 'Unknown error executing prompt';
onPromptLoadError?.(placeholderId, errorMessage); onPromptLoadError?.(placeholderId, errorMessage);
} }
} }
@@ -167,9 +168,9 @@
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', { console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
serverName: selectedPrompt.serverName,
promptName: selectedPrompt.name,
argName, argName,
promptName: selectedPrompt.name,
serverName: selectedPrompt.serverName,
value value
}); });
} }
@@ -187,9 +188,9 @@
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', { console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
argName, argName,
value,
result, result,
suggestionsCount: result?.values.length ?? 0 suggestionsCount: result?.values.length ?? 0,
value
}); });
} }
@@ -234,6 +235,7 @@
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handleCancelArgumentForm(); handleCancelArgumentForm();
return; return;
} }
@@ -274,6 +276,7 @@
selectedIndex = selectedIndexBeforeArgumentForm; selectedIndex = selectedIndexBeforeArgumentForm;
selectedIndexBeforeArgumentForm = null; selectedIndexBeforeArgumentForm = null;
} }
selectedPrompt = null; selectedPrompt = null;
promptArgs = {}; promptArgs = {};
promptError = null; promptError = null;
@@ -284,6 +287,7 @@
if (event.key === KeyboardKey.ESCAPE) { if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault(); event.preventDefault();
if (selectedPrompt) { if (selectedPrompt) {
// Return to prompt selection list, keeping the selected prompt active // Return to prompt selection list, keeping the selected prompt active
handleCancelArgumentForm(); handleCancelArgumentForm();
@@ -296,6 +300,7 @@
if (event.key === KeyboardKey.ARROW_DOWN) { if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault(); event.preventDefault();
if (filteredPrompts.length > 0) { if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length; selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
scrollTrigger++; scrollTrigger++;
@@ -306,6 +311,7 @@
if (event.key === KeyboardKey.ARROW_UP) { if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault(); event.preventDefault();
if (filteredPrompts.length > 0) { if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1; selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
scrollTrigger++; scrollTrigger++;
@@ -316,6 +322,7 @@
if (event.key === KeyboardKey.ENTER && !selectedPrompt) { if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
event.preventDefault(); event.preventDefault();
if (filteredPrompts[selectedIndex]) { if (filteredPrompts[selectedIndex]) {
handlePromptClick(filteredPrompts[selectedIndex]); handlePromptClick(filteredPrompts[selectedIndex]);
} }
@@ -329,14 +336,14 @@
let filteredPrompts = $derived.by(() => { let filteredPrompts = $derived.by(() => {
const sortedServers = mcpStore.getServers(); const sortedServers = mcpStore.getServers();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index])); const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedPrompts = [...prompts].sort((a, b) => { const sortedPrompts = [...prompts].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER; const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER; const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB; return orderA - orderB;
}); });
const query = (searchQuery || internalSearchQuery).toLowerCase(); const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedPrompts; if (!query) return sortedPrompts;
return sortedPrompts.filter( return sortedPrompts.filter(
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte'; import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import type { MCPPromptInfo } from '$lib/types';
interface Props { interface Props {
prompt: MCPPromptInfo; prompt: MCPPromptInfo;
@@ -21,20 +21,20 @@
} }
let { let {
prompt,
promptArgs,
suggestions,
loadingSuggestions,
activeAutocomplete, activeAutocomplete,
autocompleteIndex, autocompleteIndex,
promptError, loadingSuggestions,
onArgInput,
onArgKeydown,
onArgBlur, onArgBlur,
onArgFocus, onArgFocus,
onArgInput,
onArgKeydown,
onCancel,
onSelectSuggestion, onSelectSuggestion,
onSubmit, onSubmit,
onCancel prompt,
promptArgs,
promptError,
suggestions
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import { fly } from 'svelte/transition';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import type { MCPPromptInfo } from '$lib/types';
import { fly } from 'svelte/transition';
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number]; type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
@@ -22,16 +22,16 @@
let { let {
argument, argument,
value = '',
suggestions = [],
isLoadingSuggestions = false,
isAutocompleteActive = false,
autocompleteIndex = 0, autocompleteIndex = 0,
onInput, isAutocompleteActive = false,
onKeydown, isLoadingSuggestions = false,
onBlur, onBlur,
onFocus, onFocus,
onSelectSuggestion onInput,
onKeydown,
onSelectSuggestion,
suggestions = [],
value = ''
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -66,7 +66,7 @@
{#if isAutocompleteActive && suggestions.length > 0} {#if isAutocompleteActive && suggestions.length > 0}
<div <div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg" class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }} transition:fly={{ duration: 100, y: -5 }}
> >
{#each suggestions as suggestion, i (suggestion)} {#each suggestions as suggestion, i (suggestion)}
<button <button
@@ -35,24 +35,24 @@
} }
let { let {
isCommandPickerOpen,
commandQuery, commandQuery,
commands = [], commands = [],
isCommandPickerOpen,
isMentionPickerOpen,
isPromptPickerOpen,
mentionAnchor,
mentionQuery,
onCommandPickerClose, onCommandPickerClose,
onCommandSelect, onCommandSelect,
isPromptPickerOpen,
promptSearchQuery,
isMentionPickerOpen,
mentionQuery,
mentionAnchor,
scopePath,
onPromptPickerClose,
onMentionPickerClose,
onMentionOpened, onMentionOpened,
onMentionPickerClose,
onMentionSelect, onMentionSelect,
onPromptLoadStart,
onPromptLoadComplete, onPromptLoadComplete,
onPromptLoadError onPromptLoadError,
onPromptLoadStart,
onPromptPickerClose,
promptSearchQuery,
scopePath
}: Props = $props(); }: Props = $props();
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined); let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
@@ -52,6 +52,7 @@
// the picker/paste flows can address either renderer through one handle. // the picker/paste flows can address either renderer through one handle.
export function getCaretOffset(): number { export function getCaretOffset(): number {
if (!textareaElement) return 0; if (!textareaElement) return 0;
return textareaElement.selectionStart ?? textareaElement.value.length; return textareaElement.selectionStart ?? textareaElement.value.length;
} }
@@ -1,23 +1,9 @@
<script lang="ts"> <script lang="ts">
import { FolderOpen } from '@lucide/svelte';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
import {
abbreviateHome,
buildCaseInsensitiveGlob,
joinPath,
lastPathSegment,
runGlobSearchWithChildren,
type GlobEntry
} from '$lib/utils';
import * as Popover from '$lib/components/ui/popover';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte'; import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte'; import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
import { FolderOpen } from '@lucide/svelte';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Popover from '$lib/components/ui/popover';
import { import {
DEFAULT_MOBILE_BREAKPOINT, DEFAULT_MOBILE_BREAKPOINT,
HOME_TILDE, HOME_TILDE,
@@ -28,6 +14,20 @@
SEARCH_LIMIT, SEARCH_LIMIT,
SEARCH_MAX_DEPTH SEARCH_MAX_DEPTH
} from '$lib/constants'; } from '$lib/constants';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores/tools.svelte';
import {
abbreviateHome,
buildCaseInsensitiveGlob,
type GlobEntry,
joinPath,
lastPathSegment,
runGlobSearchWithChildren
} from '$lib/utils';
// Microtask delay so the popover's focus scope tears down first. // Microtask delay so the popover's focus scope tears down first.
const FOCUS_DELAY_MS = 0; const FOCUS_DELAY_MS = 0;
@@ -52,14 +52,14 @@
let { let {
class: className = '', class: className = '',
disabled = false,
directory = null,
isOpen,
query = $bindable(''),
customAnchor = null, customAnchor = null,
directory = null,
disabled = false,
isOpen,
onChange, onChange,
onClose, onClose,
onOpen onOpen,
query = $bindable('')
}: Props = $props(); }: Props = $props();
// File System Access API is opt-in (Chrome / Edge / Opera): the popover // File System Access API is opt-in (Chrome / Edge / Opera): the popover
@@ -88,8 +88,8 @@
let listContainer = $state<HTMLDivElement | null>(null); let listContainer = $state<HTMLDivElement | null>(null);
const nav = usePickerNavigation({ const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => queryResults.length, count: () => queryResults.length,
isOpen: () => isOpen,
onClose: closePicker, onClose: closePicker,
onSelect: (index) => commit(queryResults[index]) onSelect: (index) => commit(queryResults[index])
}); });
@@ -99,19 +99,24 @@
// Resolve home eagerly so the chip can abbreviate before the picker opens. // Resolve home eagerly so the chip can abbreviate before the picker opens.
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome(); void toolsStore.resolveServerHome();
}); });
// HTML `autofocus` is unreliable on dynamically shown elements. // HTML `autofocus` is unreliable on dynamically shown elements.
$effect(() => { $effect(() => {
if (!isOpen) return; if (!isOpen) return;
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS); setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
}); });
$effect(() => { $effect(() => {
if (!isOpen) return; if (!isOpen) return;
const q = query.trim(); const q = query.trim();
nav.reset(-1); nav.reset(-1);
if (q && fileSearchEnabled) { if (q && fileSearchEnabled) {
search.run(q); search.run(q);
} else { } else {
@@ -124,11 +129,11 @@
}); });
useScrollActiveRow({ useScrollActiveRow({
getTrigger: () => nav.scrollTrigger, dataIndex: 'result',
getContainer: () => listContainer, getContainer: () => listContainer,
getIndex: () => nav.hoveredIndex,
getCount: () => queryResults.length, getCount: () => queryResults.length,
dataIndex: 'result' getIndex: () => nav.hoveredIndex,
getTrigger: () => nav.scrollTrigger
}); });
let searchScope = $state(HOME_TILDE); let searchScope = $state(HOME_TILDE);
@@ -136,16 +141,18 @@
// An exactly-typed directory is "entered": the shared search lists its // An exactly-typed directory is "entered": the shared search lists its
// children too, so path navigation does not require a trailing slash. // children too, so path navigation does not require a trailing slash.
const search = useDebouncedSearch({ const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen && fileSearchEnabled, canRun: () => isOpen && fileSearchEnabled,
debounceMs: SEARCH_DEBOUNCE_MS,
getQuery: () => query.trim(), getQuery: () => query.trim(),
run: async (q, signal, isCurrent) => { run: async (q, signal, isCurrent) => {
const trimmed = q.trim(); const trimmed = q.trim();
if (!trimmed) { if (!trimmed) {
queryResults = []; queryResults = [];
searchError = null; searchError = null;
nav.reset(-1); nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE; searchScope = homeBase ?? HOME_TILDE;
return; return;
} }
@@ -160,25 +167,31 @@
signal, signal,
{ type: GlobSearchType.DIR } { type: GlobSearchType.DIR }
); );
if (!isCurrent()) return; if (!isCurrent()) return;
if (res.error) { if (res.error) {
queryResults = []; queryResults = [];
nav.reset(-1); nav.reset(-1);
searchError = res.error; searchError = res.error;
return; return;
} }
searchScope = res.exactDir ?? res.args.path; searchScope = res.exactDir ?? res.args.path;
queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN); queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
if (queryResults.length > 0) { if (queryResults.length > 0) {
nav.reset(0); nav.reset(0);
nav.bumpScroll(); // scroll the list back to the top (first item is hovered) nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
} else { } else {
nav.reset(-1); nav.reset(-1);
} }
searchError = null; searchError = null;
} catch (err) { } catch (err) {
if (!isCurrent() || signal.aborted) return; if (!isCurrent() || signal.aborted) return;
queryResults = []; queryResults = [];
nav.reset(-1); nav.reset(-1);
searchError = err instanceof Error ? err.message : String(err); searchError = err instanceof Error ? err.message : String(err);
@@ -197,7 +210,9 @@
function setDirectory(value: string) { function setDirectory(value: string) {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return; if (!trimmed) return;
onChange?.(trimmed); onChange?.(trimmed);
} }
@@ -207,17 +222,18 @@
async function resolveNativeName(name: string): Promise<string | null> { async function resolveNativeName(name: string): Promise<string | null> {
try { try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, { const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
path: homeBase ?? HOME_TILDE,
type: GlobSearchType.DIR,
include: buildCaseInsensitiveGlob(name), include: buildCaseInsensitiveGlob(name),
limit: NATIVE_LIMIT,
max_depth: NATIVE_MAX_DEPTH, max_depth: NATIVE_MAX_DEPTH,
limit: NATIVE_LIMIT path: homeBase ?? HOME_TILDE,
type: GlobSearchType.DIR
}); });
const base = typeof res.base === 'string' ? res.base : ''; const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : []; const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const match = entries.find( const match = entries.find(
(e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase() (e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
); );
return match ? joinPath(base, match.path) : null; return match ? joinPath(base, match.path) : null;
} catch { } catch {
return null; return null;
@@ -226,9 +242,11 @@
async function browseNative() { async function browseNative() {
if (disabled || !window.showDirectoryPicker) return; if (disabled || !window.showDirectoryPicker) return;
try { try {
const handle = await window.showDirectoryPicker(); const handle = await window.showDirectoryPicker();
const path = await resolveNativeName(handle.name); const path = await resolveNativeName(handle.name);
if (path) { if (path) {
setDirectory(path); setDirectory(path);
closePicker(); closePicker();
@@ -240,16 +258,20 @@
} catch (err) { } catch (err) {
// user cancelled - silently ignore; other errors are logged // user cancelled - silently ignore; other errors are logged
if (err instanceof DOMException && err.name === 'AbortError') return; if (err instanceof DOMException && err.name === 'AbortError') return;
console.error('[ChatFormWorkingDirectory] showDirectoryPicker failed:', err); console.error('[ChatFormWorkingDirectory] showDirectoryPicker failed:', err);
} }
} }
function handleSubmit() { function handleSubmit() {
const value = query.trim(); const value = query.trim();
if (!value) { if (!value) {
closePicker(); closePicker();
return; return;
} }
setDirectory(value); setDirectory(value);
closePicker(); closePicker();
} }
@@ -257,6 +279,7 @@
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER) { if (event.key === KeyboardKey.ENTER) {
event.preventDefault(); event.preventDefault();
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) { if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
commit(queryResults[nav.hoveredIndex]); commit(queryResults[nav.hoveredIndex]);
} else if (queryResults.length === 0) { } else if (queryResults.length === 0) {
@@ -287,6 +310,7 @@
function handleDismiss(event?: MouseEvent) { function handleDismiss(event?: MouseEvent) {
event?.stopPropagation(); event?.stopPropagation();
event?.preventDefault(); event?.preventDefault();
if (directory) { if (directory) {
clearDirectory(event); clearDirectory(event);
} }
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Folder, X } from '@lucide/svelte'; import { Folder, X } from '@lucide/svelte';
import { abbreviateWorkingDir } from '$lib/utils';
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIcon } from '$lib/components/app/actions'; import { ActionIcon } from '$lib/components/app/actions';
import * as Tooltip from '$lib/components/ui/tooltip';
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
import { abbreviateWorkingDir } from '$lib/utils';
interface Props { interface Props {
directory?: string | null; directory?: string | null;
@@ -15,10 +15,10 @@
let { let {
directory = null, directory = null,
homeBase = null,
disabled = false, disabled = false,
showTooltip = false, homeBase = null,
onClear onClear,
showTooltip = false
}: Props = $props(); }: Props = $props();
const displayLabel = $derived( const displayLabel = $derived(
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Folder } from '@lucide/svelte'; import { Folder } from '@lucide/svelte';
import { fly } from 'svelte/transition';
import { highlightMatch } from '$lib/utils';
import { cn } from '$lib/components/ui/utils'; import { cn } from '$lib/components/ui/utils';
import { highlightMatch } from '$lib/utils';
import { fly } from 'svelte/transition';
// Fly-in transition for the results list. // Fly-in transition for the results list.
const FLY_Y_PX = -4; const FLY_Y_PX = -4;
@@ -20,21 +20,21 @@
} }
let { let {
results, container = $bindable(null),
error,
hoveredIndex, hoveredIndex,
isSearching, isSearching,
error,
rawQuery,
container = $bindable(null),
onCommit, onCommit,
onHover onHover,
rawQuery,
results
}: Props = $props(); }: Props = $props();
</script> </script>
<div <div
bind:this={container} bind:this={container}
class="max-h-48 overflow-y-auto py-2" class="max-h-48 overflow-y-auto py-2"
transition:fly={{ y: FLY_Y_PX, duration: FLY_DURATION_MS }} transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
> >
{#if isSearching && results.length === 0} {#if isSearching && results.length === 0}
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div> <div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
@@ -1,24 +1,24 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services/database.service';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { REASONING_TAGS } from '$lib/constants/agentic';
import { MessageRole, AttachmentType, AgenticSectionType } from '$lib/enums';
import { import {
ChatMessageAssistant, ChatMessageAssistant,
ChatMessageUser, ChatMessageMcpPrompt,
ChatMessageSystem,
ChatMessageSynthetic, ChatMessageSynthetic,
ChatMessageMcpPrompt ChatMessageSystem,
ChatMessageUser
} from '$lib/components/app/chat'; } from '$lib/components/app/chat';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only'; import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { deriveAgenticSections } from '$lib/utils'; import { REASONING_TAGS } from '$lib/constants/agentic';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { ROUTES } from '$lib/constants/routes'; import { ROUTES } from '$lib/constants/routes';
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { deriveAgenticSections } from '$lib/utils';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
interface Props { interface Props {
class?: string; class?: string;
@@ -32,12 +32,12 @@
let { let {
class: className = '', class: className = '',
message,
toolMessages = [],
isLastAssistantMessage = false, isLastAssistantMessage = false,
isLastUserMessage = false, isLastUserMessage = false,
message,
nextAssistantMessage = null, nextAssistantMessage = null,
siblingInfo = null siblingInfo = null,
toolMessages = []
}: Props = $props(); }: Props = $props();
const chatActions = getChatActionsContext(); const chatActions = getChatActionsContext();
@@ -72,10 +72,12 @@
case AgenticSectionType.REASONING: case AgenticSectionType.REASONING:
case AgenticSectionType.REASONING_PENDING: case AgenticSectionType.REASONING_PENDING:
parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`); parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
break; break;
case AgenticSectionType.TEXT: case AgenticSectionType.TEXT:
parts.push(section.content); parts.push(section.content);
break; break;
case AgenticSectionType.TOOL_CALL: case AgenticSectionType.TOOL_CALL:
@@ -115,9 +117,7 @@
let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT); let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT);
setMessageEditContext({ setMessageEditContext({
get isEditing() { cancel: handleCancelEdit,
return isEditing;
},
get editedContent() { get editedContent() {
return editedContent; return editedContent;
}, },
@@ -127,6 +127,12 @@
get editedUploadedFiles() { get editedUploadedFiles() {
return editedUploadedFiles; return editedUploadedFiles;
}, },
get isEditing() {
return isEditing;
},
get messageRole() {
return message.role;
},
get originalContent() { get originalContent() {
return message.role === MessageRole.ASSISTANT return message.role === MessageRole.ASSISTANT
? (rawEditContent ?? message.content) ? (rawEditContent ?? message.content)
@@ -135,42 +141,40 @@
get originalExtras() { get originalExtras() {
return message.extra || []; return message.extra || [];
}, },
get showSaveOnlyOption() {
return showSaveOnlyOption;
},
get showBranchAfterEditOption() {
return showBranchAfterEditOption;
},
get shouldBranchAfterEdit() {
return shouldBranchAfterEdit;
},
get messageRole() {
return message.role;
},
get rawEditContent() { get rawEditContent() {
return rawEditContent; return rawEditContent;
}, },
save: handleSaveEdit,
saveOnly: handleSaveEditOnly,
setContent: (content: string) => { setContent: (content: string) => {
editedContent = content; editedContent = content;
}, },
setExtras: (extras: DatabaseMessageExtra[]) => { setExtras: (extras: DatabaseMessageExtra[]) => {
editedExtras = extras; editedExtras = extras;
}, },
setUploadedFiles: (files: ChatUploadedFile[]) => {
editedUploadedFiles = files;
},
setShouldBranchAfterEdit: (value: boolean) => { setShouldBranchAfterEdit: (value: boolean) => {
shouldBranchAfterEdit = value; shouldBranchAfterEdit = value;
}, },
save: handleSaveEdit, setUploadedFiles: (files: ChatUploadedFile[]) => {
saveOnly: handleSaveEditOnly, editedUploadedFiles = files;
cancel: handleCancelEdit, },
get shouldBranchAfterEdit() {
return shouldBranchAfterEdit;
},
get showBranchAfterEditOption() {
return showBranchAfterEditOption;
},
get showSaveOnlyOption() {
return showSaveOnlyOption;
},
startEdit: handleEdit startEdit: handleEdit
}); });
let mcpPromptExtra = $derived.by(() => { let mcpPromptExtra = $derived.by(() => {
if (message.role !== MessageRole.USER) return null; if (message.role !== MessageRole.USER) return null;
if (message.content.trim()) return null; if (message.content.trim()) return null;
if (!message.extra || message.extra.length !== 1) return null; if (!message.extra || message.extra.length !== 1) return null;
const extra = message.extra[0]; const extra = message.extra[0];
@@ -238,6 +242,7 @@
function handleEdit() { function handleEdit() {
isEditing = true; isEditing = true;
// Clear temporary placeholder content for system messages // Clear temporary placeholder content for system messages
if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) { if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) {
editedContent = ''; editedContent = '';
@@ -281,6 +286,7 @@
// After the system message flow ends, hand focus to the main chat form // After the system message flow ends, hand focus to the main chat form
function focusMainChatForm() { function focusMainChatForm() {
if (isMobile.current) return; if (isMobile.current) return;
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus(); document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
} }
@@ -292,23 +298,29 @@
// If content is empty, remove without deleting children // If content is empty, remove without deleting children
if (!newContent) { if (!newContent) {
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id); const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
isEditing = false; isEditing = false;
if (conversationDeleted) { if (conversationDeleted) {
goto(ROUTES.START); goto(ROUTES.START);
} else { } else {
focusMainChatForm(); focusMainChatForm();
} }
return; return;
} }
await DatabaseService.updateMessage(message.id, { content: newContent }); await DatabaseService.updateMessage(message.id, { content: newContent });
const index = conversationsStore.findMessageIndex(message.id); const index = conversationsStore.findMessageIndex(message.id);
if (index !== -1) { if (index !== -1) {
conversationsStore.updateMessageAtIndex(index, { content: newContent }); conversationsStore.updateMessageAtIndex(index, { content: newContent });
} }
focusMainChatForm(); focusMainChatForm();
} else if (message.role === MessageRole.USER) { } else if (message.role === MessageRole.USER) {
const finalExtras = await getMergedExtras(); const finalExtras = await getMergedExtras();
chatActions.editWithBranching(message, editedContent.trim(), finalExtras); chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
} else { } else {
// For assistant messages, preserve exact content including trailing whitespace // For assistant messages, preserve exact content including trailing whitespace
@@ -325,6 +337,7 @@
if (message.role === MessageRole.USER) { if (message.role === MessageRole.USER) {
// For user messages, trim to avoid accidental whitespace // For user messages, trim to avoid accidental whitespace
const finalExtras = await getMergedExtras(); const finalExtras = await getMergedExtras();
chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras); chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
} }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { import {
ChatMessageAgenticContent,
ChatMessageActionIcons, ChatMessageActionIcons,
ChatMessageAgenticContent,
ChatMessageAssistantModel, ChatMessageAssistantModel,
ChatMessageAssistantProcessingInfo, ChatMessageAssistantProcessingInfo,
ChatMessageAssistantRawOutput, ChatMessageAssistantRawOutput,
@@ -9,14 +9,13 @@
ChatMessageEditForm ChatMessageEditForm
} from '$lib/components/app'; } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts'; import { getMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { modelLoadProgressText } from '$lib/utils';
import { MessageRole } from '$lib/enums'; import { MessageRole } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isRouterMode } from '$lib/stores/server.svelte'; import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import { modelsStore } from '$lib/stores/models.svelte'; import { modelsStore } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { config } from '$lib/stores/settings.svelte';
import { modelLoadProgressText } from '$lib/utils';
import { hasAgenticContent } from '$lib/utils'; import { hasAgenticContent } from '$lib/utils';
interface Props { interface Props {
@@ -49,7 +48,6 @@
deletionInfo, deletionInfo,
isLastAssistantMessage = false, isLastAssistantMessage = false,
message, message,
toolMessages = [],
onConfirmDelete, onConfirmDelete,
onContinue, onContinue,
onCopy, onCopy,
@@ -61,7 +59,8 @@
onShowDeleteDialogChange, onShowDeleteDialogChange,
showDeleteDialog, showDeleteDialog,
siblingInfo = null, siblingInfo = null,
textareaElement = $bindable() textareaElement = $bindable(),
toolMessages = []
}: Props = $props(); }: Props = $props();
// Get edit context // Get edit context
@@ -124,18 +123,21 @@
if (!userMessageEl) { if (!userMessageEl) {
lastUserMessageHeight = 0; lastUserMessageHeight = 0;
return; return;
} }
const updateHeight = () => { const updateHeight = () => {
const rect = userMessageEl.getBoundingClientRect(); const rect = userMessageEl.getBoundingClientRect();
const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop)); const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
lastUserMessageHeight = Math.round(rect.height + marginTop); lastUserMessageHeight = Math.round(rect.height + marginTop);
}; };
updateHeight(); updateHeight();
const resizeObserver = new ResizeObserver(updateHeight); const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(userMessageEl); resizeObserver.observe(userMessageEl);
return () => { return () => {
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app'; import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app';
import { copyToClipboard } from '$lib/utils';
import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums'; import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores/models.svelte';
import { copyToClipboard } from '$lib/utils';
interface Props { interface Props {
displayedModel: string | null; displayedModel: string | null;
@@ -11,7 +11,7 @@
onRegenerate: (modelOverride?: string) => void; onRegenerate: (modelOverride?: string) => void;
} }
let { displayedModel, isRouter, isLoading, onRegenerate }: Props = $props(); let { displayedModel, isLoading, isRouter, onRegenerate }: Props = $props();
let pendingModel = $state<string | null>(null); let pendingModel = $state<string | null>(null);
@@ -38,6 +38,7 @@
} }
onRegenerate(modelName); onRegenerate(modelName);
return true; return true;
}} }}
/> />
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition';
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte'; import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
import { fade } from 'svelte/transition';
interface Props { interface Props {
modelLoadingText: string | null; modelLoadingText: string | null;
@@ -8,7 +8,7 @@
position: 'top' | 'bottom'; position: 'top' | 'bottom';
} }
let { modelLoadingText, processingState, position }: Props = $props(); let { modelLoadingText, position, processingState }: Props = $props();
const marginClass = position === 'top' ? 'mt-6' : 'mt-4'; const marginClass = position === 'top' ? 'mt-6' : 'mt-4';
</script> </script>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { deriveAgenticSections, buildAssistantRawOutput } from '$lib/utils'; import { buildAssistantRawOutput, deriveAgenticSections } from '$lib/utils';
interface Props { interface Props {
message: DatabaseMessage; message: DatabaseMessage;
@@ -10,6 +10,7 @@
let rawOutputContent = $derived.by(() => { let rawOutputContent = $derived.by(() => {
const sections = deriveAgenticSections(message, toolMessages, [], false); const sections = deriveAgenticSections(message, toolMessages, [], false);
return buildAssistantRawOutput(sections); return buildAssistantRawOutput(sections);
}); });
</script> </script>
@@ -10,7 +10,7 @@
showMessageStats: boolean; showMessageStats: boolean;
} }
let { message, isLoading, processingState, showMessageStats }: Props = $props(); let { isLoading, message, processingState, showMessageStats }: Props = $props();
</script> </script>
{#if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms} {#if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Folder, FolderX } from '@lucide/svelte'; import { Folder, FolderX } from '@lucide/svelte';
import { parseCwdMessage } from '$lib/utils';
import type { DatabaseMessage } from '$lib/types'; import type { DatabaseMessage } from '$lib/types';
import { parseCwdMessage } from '$lib/utils';
interface Props { interface Props {
class?: string; class?: string;
@@ -5,7 +5,7 @@
ChatMessageMcpPromptContent ChatMessageMcpPromptContent
} from '$lib/components/app'; } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts'; import { getMessageEditContext } from '$lib/contexts';
import { MessageRole, McpPromptVariant } from '$lib/enums'; import { McpPromptVariant, MessageRole } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types'; import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props { interface Props {
@@ -30,17 +30,17 @@
let { let {
class: className = '', class: className = '',
message,
mcpPrompt,
siblingInfo = null,
showDeleteDialog,
deletionInfo, deletionInfo,
onCopy, mcpPrompt,
onEdit, message,
onDelete,
onConfirmDelete, onConfirmDelete,
onCopy,
onDelete,
onEdit,
onNavigateToSibling, onNavigateToSibling,
onShowDeleteDialogChange onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null
}: Props = $props(); }: Props = $props();
// Get edit context // Get edit context
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Card } from '$lib/components/ui/card';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { SvelteMap } from 'svelte/reactivity';
import { McpPromptVariant } from '$lib/enums';
import { TruncatedText } from '$lib/components/app/misc'; import { TruncatedText } from '$lib/components/app/misc';
import { Card } from '$lib/components/ui/card';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { McpPromptVariant } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
interface ContentPart { interface ContentPart {
text: string; text: string;
@@ -22,10 +22,10 @@
let { let {
class: className = '', class: className = '',
prompt,
variant = McpPromptVariant.MESSAGE,
isLoading = false, isLoading = false,
loadError loadError,
prompt,
variant = McpPromptVariant.MESSAGE
}: Props = $props(); }: Props = $props();
let hoveredArgKey = $state<string | null>(null); let hoveredArgKey = $state<string | null>(null);
@@ -35,13 +35,15 @@
let contentParts = $derived.by((): ContentPart[] => { let contentParts = $derived.by((): ContentPart[] => {
if (!prompt.content || !hasArguments) { if (!prompt.content || !hasArguments) {
return [{ text: prompt.content || '', argKey: null }]; return [{ argKey: null, text: prompt.content || '' }];
} }
const parts: ContentPart[] = []; const parts: ContentPart[] = [];
let remaining = prompt.content; let remaining = prompt.content;
const valueToKey = new SvelteMap<string, string>(); const valueToKey = new SvelteMap<string, string>();
for (const [key, value] of argumentEntries) { for (const [key, value] of argumentEntries) {
if (value && value.trim()) { if (value && value.trim()) {
valueToKey.set(value, key); valueToKey.set(value, key);
@@ -55,20 +57,21 @@
for (const value of sortedValues) { for (const value of sortedValues) {
const index = remaining.indexOf(value); const index = remaining.indexOf(value);
if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) { if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
earliestMatch = { index, value, key: valueToKey.get(value)! }; earliestMatch = { index, key: valueToKey.get(value)!, value };
} }
} }
if (earliestMatch) { if (earliestMatch) {
if (earliestMatch.index > 0) { if (earliestMatch.index > 0) {
parts.push({ text: remaining.slice(0, earliestMatch.index), argKey: null }); parts.push({ argKey: null, text: remaining.slice(0, earliestMatch.index) });
} }
parts.push({ text: earliestMatch.value, argKey: earliestMatch.key }); parts.push({ argKey: earliestMatch.key, text: earliestMatch.value });
remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length); remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
} else { } else {
parts.push({ text: remaining, argKey: null }); parts.push({ argKey: null, text: remaining });
break; break;
} }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { parseCwdMessage } from '$lib/utils';
import type { DatabaseMessage } from '$lib/types';
import ChatMessageCwdChange from './ChatMessageCwdChange.svelte'; import ChatMessageCwdChange from './ChatMessageCwdChange.svelte';
import type { DatabaseMessage } from '$lib/types';
import { parseCwdMessage } from '$lib/utils';
interface Props { interface Props {
class?: string; class?: string;
@@ -31,16 +31,16 @@
let { let {
class: className = '', class: className = '',
message,
siblingInfo = null,
showDeleteDialog,
deletionInfo, deletionInfo,
onCopy, message,
onEdit,
onDelete,
onConfirmDelete, onConfirmDelete,
onCopy,
onDelete,
onEdit,
onNavigateToSibling, onNavigateToSibling,
onShowDeleteDialogChange, onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null,
textareaElement = $bindable() textareaElement = $bindable()
}: Props = $props(); }: Props = $props();
@@ -1,12 +1,4 @@
<script lang="ts"> <script lang="ts">
import { BuiltInTool } from '$lib/enums';
import {
extractSearchQuery,
extractSearchResults,
isWebSearchToolName,
type AgenticSection
} from '$lib/utils';
import type { DatabaseMessageExtra } from '$lib/types';
import ChatMessageToolCallBlockDefault from './ChatMessageToolCallBlockDefault.svelte'; import ChatMessageToolCallBlockDefault from './ChatMessageToolCallBlockDefault.svelte';
import ChatMessageToolCallBlockEditFile from './ChatMessageToolCallBlockEditFile.svelte'; import ChatMessageToolCallBlockEditFile from './ChatMessageToolCallBlockEditFile.svelte';
import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte'; import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
@@ -18,6 +10,14 @@
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte'; import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte'; import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte'; import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
import { BuiltInTool } from '$lib/enums';
import type { DatabaseMessageExtra } from '$lib/types';
import {
type AgenticSection,
extractSearchQuery,
extractSearchResults,
isWebSearchToolName
} from '$lib/utils';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -28,7 +28,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, attachments, open, isStreaming, isExecuting, onToggle }: Props = $props(); let { attachments, isExecuting, isStreaming, onToggle, open, section }: Props = $props();
const searchResults = $derived(extractSearchResults(section.toolResult)); const searchResults = $derived(extractSearchResults(section.toolResult));
const searchQuery = $derived(extractSearchQuery(section.toolArgs)); const searchQuery = $derived(extractSearchQuery(section.toolArgs));
@@ -3,19 +3,19 @@
// Renders section.toolArgs / section.toolResult directly using the // Renders section.toolArgs / section.toolResult directly using the
// shared chrome shell. // shared chrome shell.
import ToolCallBlock from './ToolCallBlock.svelte';
import { Loader2 } from '@lucide/svelte'; import { Loader2 } from '@lucide/svelte';
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app'; import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
import { FileTypeText, ToolResultKind } from '$lib/enums';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants'; import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import { FileTypeText, ToolResultKind } from '$lib/enums';
import type { DatabaseMessageExtra } from '$lib/types';
import { import {
type AgenticSection,
classifyToolResult, classifyToolResult,
formatJsonPretty, formatJsonPretty,
parseToolResultWithImages, parseToolResultWithImages
type AgenticSection
} from '$lib/utils'; } from '$lib/utils';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import type { DatabaseMessageExtra } from '$lib/types';
import ToolCallBlock from './ToolCallBlock.svelte';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -25,7 +25,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, open, isStreaming, attachments, onToggle }: Props = $props(); let { attachments, isStreaming, onToggle, open, section }: Props = $props();
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? ''); const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
const outputKind = $derived(classifyToolResult(section.toolResult)); const outputKind = $derived(classifyToolResult(section.toolResult));
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { XCircle } from '@lucide/svelte';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { computeLineDiff, prefixFor, abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseEditFileMeta } from './parsers/edit-file'; import { parseEditFileMeta } from './parsers/edit-file';
import ToolCallBlock from './ToolCallBlock.svelte'; import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { toolsStore } from '$lib/stores/tools.svelte';
import { abbreviateHome, type AgenticSection, computeLineDiff, prefixFor } from '$lib/utils';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -13,7 +13,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, open, isStreaming, onToggle }: Props = $props(); let { isStreaming, onToggle, open, section }: Props = $props();
const editFileMeta = $derived(parseEditFileMeta(section)); const editFileMeta = $derived(parseEditFileMeta(section));
const home = $derived(toolsStore.serverHome); const home = $derived(toolsStore.serverHome);
@@ -6,26 +6,26 @@
// The scroll-to-bottom auto-scroll logic mirrors what was here // The scroll-to-bottom auto-scroll logic mirrors what was here
// before extraction. // before extraction.
import { Check, Loader2, XCircle, AlertTriangle } from '@lucide/svelte'; import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
import ToolCallBlock from './ToolCallBlock.svelte';
import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
import { CollapsibleTerminalBlock } from '$lib/components/app'; import { CollapsibleTerminalBlock } from '$lib/components/app';
import { SETTINGS_KEYS } from '$lib/constants'; import { SETTINGS_KEYS } from '$lib/constants';
import { config } from '$lib/stores/settings.svelte';
import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll'; import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
import { config } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { DatabaseMessageExtra } from '$lib/types';
import { import {
abbreviateHome, abbreviateHome,
type AgenticSection,
type ExecShellExitStatus,
highlightCode, highlightCode,
isExitCodeSummaryLine, isExitCodeSummaryLine,
parseExecShellCommandError, parseExecShellCommandError,
parseExecShellCommandExitStatus, parseExecShellCommandExitStatus,
parseToolResultWithImages, parseToolResultWithImages,
type AgenticSection,
type ExecShellExitStatus,
type ToolResultLine type ToolResultLine
} from '$lib/utils'; } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
import type { DatabaseMessageExtra } from '$lib/types';
import ToolCallBlock from './ToolCallBlock.svelte';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -39,7 +39,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, open, isStreaming, isExecuting = false, attachments, onToggle }: Props = $props(); let { attachments, isExecuting = false, isStreaming, onToggle, open, section }: Props = $props();
// `isLive` covers all in-flight phases: pre-chunk spinner and // `isLive` covers all in-flight phases: pre-chunk spinner and
// streaming itself. Frozen output (tool done while agent continues) // streaming itself. Frozen output (tool done while agent continues)
@@ -108,6 +108,7 @@
function isAtBottom(): boolean { function isAtBottom(): boolean {
if (!scrollEl) return false; if (!scrollEl) return false;
return ( return (
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <= scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
SCROLL_BOTTOM_THRESHOLD_PX SCROLL_BOTTOM_THRESHOLD_PX
@@ -116,6 +117,7 @@
function scrollToBottomOnFrame() { function scrollToBottomOnFrame() {
if (pendingFrame !== null || !scrollEl || userScrolledUp) return; if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
pendingFrame = requestAnimationFrame(() => { pendingFrame = requestAnimationFrame(() => {
pendingFrame = null; pendingFrame = null;
@@ -128,18 +130,23 @@
function handleScrollEvent() { function handleScrollEvent() {
if (!scrollEl) return; if (!scrollEl) return;
const isScrollingUp = scrollEl.scrollTop < lastScrollTop; const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
if (isScrollingUp && !isAtBottom()) { if (isScrollingUp && !isAtBottom()) {
userScrolledUp = true; userScrolledUp = true;
} else if (isAtBottom()) { } else if (isAtBottom()) {
userScrolledUp = false; userScrolledUp = false;
} }
lastScrollTop = scrollEl.scrollTop; lastScrollTop = scrollEl.scrollTop;
} }
$effect(() => { $effect(() => {
void section.toolResult; void section.toolResult;
if (!scrollEl || !autoScroll) return; if (!scrollEl || !autoScroll) return;
scrollToBottomOnFrame(); scrollToBottomOnFrame();
}); });
@@ -149,10 +156,11 @@
if (!scrollEl || !autoScroll) return; if (!scrollEl || !autoScroll) return;
const observer = new MutationObserver(() => scrollToBottomOnFrame()); const observer = new MutationObserver(() => scrollToBottomOnFrame());
observer.observe(scrollEl, { observer.observe(scrollEl, {
characterData: true,
childList: true, childList: true,
subtree: true, subtree: true
characterData: true
}); });
return () => observer.disconnect(); return () => observer.disconnect();
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { XCircle } from '@lucide/svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseFileGlobSearchMeta } from './parsers/file-glob-search'; import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
import ToolCallBlock from './ToolCallBlock.svelte'; import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -12,7 +12,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, open, isStreaming, onToggle }: Props = $props(); let { isStreaming, onToggle, open, section }: Props = $props();
const fileGlobMeta = $derived(parseFileGlobSearchMeta(section)); const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
const home = $derived(toolsStore.serverHome); const home = $derived(toolsStore.serverHome);
@@ -8,7 +8,7 @@
isStreaming?: boolean; isStreaming?: boolean;
} }
let { section, isStreaming = false }: Props = $props(); let { isStreaming = false, section }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING); const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING); const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
@@ -24,9 +24,12 @@
try { try {
const parsed: unknown = JSON.parse(toolResultString); const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>; const obj = parsed as Record<string, unknown>;
if (typeof obj.error === 'string') return { errorMessage: obj.error }; if (typeof obj.error === 'string') return { errorMessage: obj.error };
if (typeof obj.result === 'string') return { dateString: obj.result.trim() }; if (typeof obj.result === 'string') return { dateString: obj.result.trim() };
} }
} catch { } catch {
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Info, Loader2 } from '@lucide/svelte'; import { Info, Loader2 } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums'; import { AgenticSectionType } from '$lib/enums';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte'; import { toolsStore } from '$lib/stores/tools.svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
isStreaming?: boolean; isStreaming?: boolean;
} }
let { section, isStreaming = false }: Props = $props(); let { isStreaming = false, section }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING); const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING); const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
@@ -26,12 +26,15 @@
try { try {
const parsed: unknown = JSON.parse(toolResultString); const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>; const obj = parsed as Record<string, unknown>;
if (typeof obj.error === 'string') return { errorMessage: obj.error }; if (typeof obj.error === 'string') return { errorMessage: obj.error };
return { return {
os: typeof obj.os === 'string' ? obj.os : undefined, cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined,
cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined os: typeof obj.os === 'string' ? obj.os : undefined
}; };
} }
} catch { } catch {
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { XCircle } from '@lucide/svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { parseGrepSearchMeta } from './parsers/grep-search'; import { parseGrepSearchMeta } from './parsers/grep-search';
import ToolCallBlock from './ToolCallBlock.svelte'; import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -12,7 +12,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, open, isStreaming, onToggle }: Props = $props(); let { isStreaming, onToggle, open, section }: Props = $props();
const grepMeta = $derived(parseGrepSearchMeta(section)); const grepMeta = $derived(parseGrepSearchMeta(section));
const home = $derived(toolsStore.serverHome); const home = $derived(toolsStore.serverHome);
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { parseReadFileMeta } from './parsers/read-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { SyntaxHighlightedCode } from '$lib/components/app'; import { SyntaxHighlightedCode } from '$lib/components/app';
import { DEFAULT_LANGUAGE, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants'; import { DEFAULT_LANGUAGE, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { type AgenticSection } from '$lib/utils'; import { type AgenticSection } from '$lib/utils';
import { parseReadFileMeta } from './parsers/read-file';
import ToolCallBlock from './ToolCallBlock.svelte';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -12,7 +12,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, open, isStreaming, onToggle }: Props = $props(); let { isStreaming, onToggle, open, section }: Props = $props();
const readFileMeta = $derived(parseReadFileMeta(section)); const readFileMeta = $derived(parseReadFileMeta(section));
</script> </script>
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { XCircle, Terminal } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { FileTypeText } from '$lib/enums';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { getBuiltinToolUi, type AgenticSection } from '$lib/utils';
import { parseRunJavascriptMeta } from './parsers/run-javascript'; import { parseRunJavascriptMeta } from './parsers/run-javascript';
import ToolCallBlock from './ToolCallBlock.svelte'; import ToolCallBlock from './ToolCallBlock.svelte';
import { Terminal, XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { FileTypeText } from '$lib/enums';
import { type AgenticSection, getBuiltinToolUi } from '$lib/utils';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -14,7 +14,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, open, isStreaming, onToggle }: Props = $props(); let { isStreaming, onToggle, open, section }: Props = $props();
const runJsMeta = $derived(parseRunJavascriptMeta(section)); const runJsMeta = $derived(parseRunJavascriptMeta(section));
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? ''); const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
import { Globe, Loader2 } from '@lucide/svelte'; import { Globe, Loader2 } from '@lucide/svelte';
import { CollapsibleContentBlock } from '$lib/components/app'; import { CollapsibleContentBlock } from '$lib/components/app';
import * as HoverCard from '$lib/components/ui/hover-card'; import * as HoverCard from '$lib/components/ui/hover-card';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
import { AgenticSectionType } from '$lib/enums'; import { AgenticSectionType } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte';
import { import {
extractSearchResults, type AgenticSection,
extractSearchQuery, extractSearchQuery,
extractSearchResults,
faviconForUrl, faviconForUrl,
sanitizeExternalUrl, sanitizeExternalUrl,
type SearchResult, type SearchResult
type AgenticSection
} from '$lib/utils'; } from '$lib/utils';
interface Props { interface Props {
@@ -21,7 +21,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, open = $bindable(false), isStreaming = false, onToggle }: Props = $props(); let { isStreaming = false, onToggle, open = $bindable(false), section }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING); const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING); const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
@@ -43,6 +43,7 @@
// retrospective. // retrospective.
const title = $derived.by(() => { const title = $derived.by(() => {
const verb = showSpinner ? 'Searching' : 'Searched'; const verb = showSpinner ? 'Searching' : 'Searched';
return query ? `${verb} web for "${query}"` : `${verb} web`; return query ? `${verb} web for "${query}"` : `${verb} web`;
}); });
@@ -52,13 +53,16 @@
function formatPublishDate(iso: string | undefined): string | null { function formatPublishDate(iso: string | undefined): string | null {
if (!iso) return null; if (!iso) return null;
try { try {
const date = new Date(iso); const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso; if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(undefined, {
year: 'numeric', day: 'numeric',
month: 'short', month: 'short',
day: 'numeric' year: 'numeric'
}); });
} catch { } catch {
return iso; return iso;
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { parseWriteFileMeta } from './parsers/write-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte'; import { XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app'; import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants'; import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { abbreviateHome, type AgenticSection } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte'; import { toolsStore } from '$lib/stores/tools.svelte';
import { parseWriteFileMeta } from './parsers/write-file'; import { abbreviateHome, type AgenticSection } from '$lib/utils';
import ToolCallBlock from './ToolCallBlock.svelte';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -14,7 +14,7 @@
onToggle?: () => void; onToggle?: () => void;
} }
let { section, open, isStreaming, onToggle }: Props = $props(); let { isStreaming, onToggle, open, section }: Props = $props();
const writeFileMeta = $derived(parseWriteFileMeta(section)); const writeFileMeta = $derived(parseWriteFileMeta(section));
const home = $derived(toolsStore.serverHome); const home = $derived(toolsStore.serverHome);
@@ -11,12 +11,12 @@
import { Loader2, Wrench } from '@lucide/svelte'; import { Loader2, Wrench } from '@lucide/svelte';
import { CollapsibleContentBlock } from '$lib/components/app'; import { CollapsibleContentBlock } from '$lib/components/app';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes'; import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
import { AgenticSectionType } from '$lib/enums'; import { AgenticSectionType } from '$lib/enums';
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
import { mcpStore } from '$lib/stores/mcp.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte';
import type { Component, Snippet } from 'svelte';
import type { AgenticSection, BuiltinToolUiEntry } from '$lib/utils'; import type { AgenticSection, BuiltinToolUiEntry } from '$lib/utils';
import type { Component, Snippet } from 'svelte';
type ToolCallBlockMetaWithError = TMeta & { errorMessage?: string }; type ToolCallBlockMetaWithError = TMeta & { errorMessage?: string };
@@ -64,17 +64,17 @@
} }
let { let {
section, children,
open, extraLiveStreaming = false,
isStreaming, isStreaming,
meta, meta,
extraLiveStreaming = false, onToggle,
open,
section,
spinIconWhenActive = false, spinIconWhenActive = false,
wrapper: Wrapper = CollapsibleContentBlock,
title, title,
titleSnippet, titleSnippet,
onToggle, wrapper: Wrapper = CollapsibleContentBlock
children
}: Props = $props(); }: Props = $props();
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING); const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
@@ -102,8 +102,11 @@
// signals activity; only terminal states get a pill. // signals activity; only terminal states get a pill.
function subtitleFor(errorMessage?: string): string | undefined { function subtitleFor(errorMessage?: string): string | undefined {
if (showSpinner) return undefined; if (showSpinner) return undefined;
if (errorMessage) return 'failed'; if (errorMessage) return 'failed';
if (isStreamingCall && !isStreaming) return 'incomplete'; if (isStreamingCall && !isStreaming) return 'incomplete';
return undefined; return undefined;
} }
@@ -122,9 +125,9 @@
{onToggle} {onToggle}
> >
{@render children(meta, { {@render children(meta, {
isStreaming, isCodeStreaming,
isPending, isPending,
isStreamingCall, isStreaming,
isCodeStreaming isStreamingCall
})} })}
</Wrapper> </Wrapper>
@@ -5,8 +5,8 @@
// stay focused on its own format quirks. // stay focused on its own format quirks.
import { BuiltInTool } from '$lib/enums'; import { BuiltInTool } from '$lib/enums';
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
import type { AgenticSection } from '$lib/utils/agentic'; import type { AgenticSection } from '$lib/utils/agentic';
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
/** /**
* Strict (final-state) JSON parser for a tool-args blob. Mirrors the * Strict (final-state) JSON parser for a tool-args blob. Mirrors the
@@ -17,9 +17,11 @@ import type { AgenticSection } from '$lib/utils/agentic';
function parseFinalToolArgs(blob: string): Record<string, unknown> | null { function parseFinalToolArgs(blob: string): Record<string, unknown> | null {
try { try {
const parsed: unknown = JSON.parse(blob); const parsed: unknown = JSON.parse(blob);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>; return parsed as Record<string, unknown>;
} }
return null; return null;
} catch { } catch {
return null; return null;
@@ -43,6 +45,7 @@ export function parseToolArgs(
options: { partial?: boolean } = {} options: { partial?: boolean } = {}
): Record<string, unknown> | null { ): Record<string, unknown> | null {
if (section.toolName !== expected || !section.toolArgs) return null; if (section.toolName !== expected || !section.toolArgs) return null;
return options.partial return options.partial
? parsePartialJsonArgs(section.toolArgs) ? parsePartialJsonArgs(section.toolArgs)
: parseFinalToolArgs(section.toolArgs); : parseFinalToolArgs(section.toolArgs);
@@ -3,10 +3,10 @@
// rendering), plus the result blob for `result` / `edits_applied` / // rendering), plus the result blob for `result` / `edits_applied` /
// `error` fields. // `error` fields.
import { BuiltInTool } from '$lib/enums';
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { tryParseToolResultObject, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared'; import { parseToolArgs } from './_shared';
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, tryParseToolResultObject } from '$lib/utils';
export type EditFileEdit = { export type EditFileEdit = {
oldText: string; oldText: string;
@@ -24,48 +24,57 @@ export type EditFileMeta = {
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null { export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
const args = parseToolArgs(BuiltInTool.EDIT_FILE, section, { partial: true }); const args = parseToolArgs(BuiltInTool.EDIT_FILE, section, { partial: true });
if (!args) return null; if (!args) return null;
const rawPath = args.path ?? args.file_path ?? args.filePath; const rawPath = args.path ?? args.file_path ?? args.filePath;
if (typeof rawPath !== 'string' || !rawPath) return null; if (typeof rawPath !== 'string' || !rawPath) return null;
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
// Filter the streamed edits array strictly: each entry must be an // Filter the streamed edits array strictly: each entry must be an
// object with a non-empty `old_text`. Edits without an old_text // object with a non-empty `old_text`. Edits without an old_text
// would diff against empty and render as a full re-write. // would diff against empty and render as a full re-write.
const rawEdits = Array.isArray(args.edits) ? args.edits : []; const rawEdits = Array.isArray(args.edits) ? args.edits : [];
const edits: EditFileEdit[] = []; const edits: EditFileEdit[] = [];
for (const e of rawEdits) { for (const e of rawEdits) {
if (!e || typeof e !== 'object' || Array.isArray(e)) continue; if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
const obj = e as Record<string, unknown>; const obj = e as Record<string, unknown>;
const oldText = typeof obj.old_text === 'string' ? obj.old_text : ''; const oldText = typeof obj.old_text === 'string' ? obj.old_text : '';
if (!oldText) continue; if (!oldText) continue;
const newText = typeof obj.new_text === 'string' ? obj.new_text : ''; const newText = typeof obj.new_text === 'string' ? obj.new_text : '';
edits.push({ oldText, newText });
edits.push({ newText, oldText });
} }
const resultObj = tryParseToolResultObject(section.toolResult); const resultObj = tryParseToolResultObject(section.toolResult);
let resultMessage: string | undefined; let resultMessage: string | undefined;
let editsApplied: number | undefined; let editsApplied: number | undefined;
let errorMessage: string | undefined; let errorMessage: string | undefined;
if (typeof resultObj?.error === 'string') { if (typeof resultObj?.error === 'string') {
errorMessage = resultObj.error; errorMessage = resultObj.error;
} else if (resultObj) { } else if (resultObj) {
if (typeof resultObj.result === 'string') { if (typeof resultObj.result === 'string') {
resultMessage = resultObj.result; resultMessage = resultObj.result;
} }
if (Number.isFinite(Number(resultObj.edits_applied))) { if (Number.isFinite(Number(resultObj.edits_applied))) {
editsApplied = Number(resultObj.edits_applied); editsApplied = Number(resultObj.edits_applied);
} }
} }
return { return {
edits,
editsApplied,
errorMessage,
fileName, fileName,
filePath: rawPath, filePath: rawPath,
edits, resultMessage
resultMessage,
editsApplied,
errorMessage
}; };
} }
@@ -5,9 +5,9 @@
// file only deals with what's strictly about *calling* the tool, since // file only deals with what's strictly about *calling* the tool, since
// the error / exit status elide from call-section to result-section. // the error / exit status elide from call-section to result-section.
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums'; import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/utils'; import type { AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
export type ExecShellCommandMeta = { export type ExecShellCommandMeta = {
command: string; command: string;
@@ -15,9 +15,12 @@ export type ExecShellCommandMeta = {
export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null { export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null {
const args = parseToolArgs(BuiltInTool.EXEC_SHELL_COMMAND, section); const args = parseToolArgs(BuiltInTool.EXEC_SHELL_COMMAND, section);
if (!args) return null; if (!args) return null;
const commandRaw = args.command ?? args.cmd ?? args.shell_command; const commandRaw = args.command ?? args.cmd ?? args.shell_command;
if (typeof commandRaw !== 'string' || !commandRaw) return null; if (typeof commandRaw !== 'string' || !commandRaw) return null;
return { command: commandRaw }; return { command: commandRaw };
} }
@@ -4,9 +4,9 @@
// parser keeps the original raw-text fallback for MCP servers that // parser keeps the original raw-text fallback for MCP servers that
// emit unparseable output. // emit unparseable output.
import { BuiltInTool } from '$lib/enums';
import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared'; import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, splitSearchSummaryList } from '$lib/utils';
export type FileGlobSearchMeta = { export type FileGlobSearchMeta = {
path: string; path: string;
@@ -19,11 +19,13 @@ export type FileGlobSearchMeta = {
export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearchMeta | null { export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearchMeta | null {
const args = parseToolArgs(BuiltInTool.FILE_GLOB_SEARCH, section); const args = parseToolArgs(BuiltInTool.FILE_GLOB_SEARCH, section);
if (!args) return null; if (!args) return null;
const path = typeof args.path === 'string' ? args.path : ''; const path = typeof args.path === 'string' ? args.path : '';
const include = typeof args.include === 'string' && args.include ? args.include : '**'; const include = typeof args.include === 'string' && args.include ? args.include : '**';
const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined; const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined;
if (!path) return null; if (!path) return null;
let matches: string[] = []; let matches: string[] = [];
@@ -31,17 +33,21 @@ export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearch
let errorMessage: string | undefined; let errorMessage: string | undefined;
const toolResultString = section.toolResult; const toolResultString = section.toolResult;
if (toolResultString) { if (toolResultString) {
try { try {
const parsed: unknown = JSON.parse(toolResultString); const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>; const obj = parsed as Record<string, unknown>;
if (typeof obj.error === 'string') { if (typeof obj.error === 'string') {
errorMessage = obj.error; errorMessage = obj.error;
} else if (typeof obj.plain_text_response === 'string') { } else if (typeof obj.plain_text_response === 'string') {
const split = splitSearchSummaryList(obj.plain_text_response, (total) => { const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
totalMatches = total; totalMatches = total;
}); });
matches = split.lines; matches = split.lines;
} }
} }
@@ -50,9 +56,10 @@ export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearch
const split = splitSearchSummaryList(toolResultString, (total) => { const split = splitSearchSummaryList(toolResultString, (total) => {
totalMatches = total; totalMatches = total;
}); });
matches = split.lines; matches = split.lines;
} }
} }
return { path, include, exclude, matches, totalMatches, errorMessage }; return { errorMessage, exclude, include, matches, path, totalMatches };
} }
@@ -5,9 +5,9 @@
// fallback so MCP servers that return unparseable output still get // fallback so MCP servers that return unparseable output still get
// surfaced. // surfaced.
import { BuiltInTool } from '$lib/enums';
import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared'; import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import { type AgenticSection, splitSearchSummaryList } from '$lib/utils';
export type GrepSearchMatch = { export type GrepSearchMatch = {
file: string; file: string;
@@ -28,10 +28,12 @@ export type GrepSearchMeta = {
export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | null { export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | null {
const args = parseToolArgs(BuiltInTool.GREP_SEARCH, section); const args = parseToolArgs(BuiltInTool.GREP_SEARCH, section);
if (!args) return null; if (!args) return null;
const path = typeof args.path === 'string' ? args.path : ''; const path = typeof args.path === 'string' ? args.path : '';
const pattern = typeof args.pattern === 'string' ? args.pattern : ''; const pattern = typeof args.pattern === 'string' ? args.pattern : '';
if (!path || !pattern) return null; if (!path || !pattern) return null;
const include = typeof args.include === 'string' && args.include ? args.include : '**'; const include = typeof args.include === 'string' && args.include ? args.include : '**';
@@ -43,17 +45,21 @@ export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | n
let errorMessage: string | undefined; let errorMessage: string | undefined;
const toolResultString = section.toolResult; const toolResultString = section.toolResult;
if (toolResultString) { if (toolResultString) {
try { try {
const parsed: unknown = JSON.parse(toolResultString); const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>; const obj = parsed as Record<string, unknown>;
if (typeof obj.error === 'string') { if (typeof obj.error === 'string') {
errorMessage = obj.error; errorMessage = obj.error;
} else if (typeof obj.plain_text_response === 'string') { } else if (typeof obj.plain_text_response === 'string') {
const split = splitSearchSummaryList(obj.plain_text_response, (total) => { const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
totalMatches = total; totalMatches = total;
}); });
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers)); matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
} }
} }
@@ -64,19 +70,20 @@ export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | n
const split = splitSearchSummaryList(toolResultString, (total) => { const split = splitSearchSummaryList(toolResultString, (total) => {
totalMatches = total; totalMatches = total;
}); });
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers)); matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
} }
} }
return { return {
errorMessage,
exclude,
include,
matches,
path, path,
pattern, pattern,
include,
exclude,
showLineNumbers, showLineNumbers,
matches, totalMatches
totalMatches,
errorMessage
}; };
} }
@@ -85,24 +92,29 @@ function parseGrepLine(line: string, showLineNumbers: boolean): GrepSearchMatch
// <file>:<content> when return_line_numbers=false // <file>:<content> when return_line_numbers=false
// <file>:<lineno>:<content> when return_line_numbers=true // <file>:<lineno>:<content> when return_line_numbers=true
const firstColon = line.indexOf(':'); const firstColon = line.indexOf(':');
if (firstColon === -1) { if (firstColon === -1) {
return { file: line, content: '' }; return { content: '', file: line };
} }
const file = line.slice(0, firstColon); const file = line.slice(0, firstColon);
const tail = line.slice(firstColon + 1); const tail = line.slice(firstColon + 1);
if (!showLineNumbers) { if (!showLineNumbers) {
return { file, content: tail }; return { content: tail, file };
} }
const secondColon = tail.indexOf(':'); const secondColon = tail.indexOf(':');
if (secondColon === -1) { if (secondColon === -1) {
return { file, content: tail }; return { content: tail, file };
} }
const lineNum = parseInt(tail.slice(0, secondColon), 10); const lineNum = parseInt(tail.slice(0, secondColon), 10);
return { return {
content: tail.slice(secondColon + 1),
file, file,
line: Number.isFinite(lineNum) ? lineNum : undefined, line: Number.isFinite(lineNum) ? lineNum : undefined
content: tail.slice(secondColon + 1)
}; };
} }
@@ -3,14 +3,14 @@
// `start_line`+`line_count`). Args are parsed partially so a header // `start_line`+`line_count`). Args are parsed partially so a header
// can render incrementally as the file path streams in. // can render incrementally as the file path streams in.
import { BuiltInTool } from '$lib/enums'; import { parseToolArgs } from './_shared';
import { import {
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
FILE_PATH_SEPARATOR_REGEX, FILE_PATH_SEPARATOR_REGEX,
TEXT_LANGUAGE_PREFIX_REGEX TEXT_LANGUAGE_PREFIX_REGEX
} from '$lib/constants'; } from '$lib/constants';
import { getFileTypeByExtension, type AgenticSection } from '$lib/utils'; import { BuiltInTool } from '$lib/enums';
import { parseToolArgs } from './_shared'; import { type AgenticSection, getFileTypeByExtension } from '$lib/utils';
export type ReadFileMeta = { export type ReadFileMeta = {
fileName: string; fileName: string;
@@ -20,13 +20,14 @@ export type ReadFileMeta = {
export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null { export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null {
const args = parseToolArgs(BuiltInTool.READ_FILE, section, { partial: true }); const args = parseToolArgs(BuiltInTool.READ_FILE, section, { partial: true });
if (!args) return null; if (!args) return null;
const rawPath = args.path ?? args.file_path ?? args.filePath; const rawPath = args.path ?? args.file_path ?? args.filePath;
if (typeof rawPath !== 'string' || !rawPath) return null; if (typeof rawPath !== 'string' || !rawPath) return null;
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
// Models emit range arguments under several aliases. Accept all to // Models emit range arguments under several aliases. Accept all to
// stay forgiving across prompt variations. // stay forgiving across prompt variations.
const startRaw = args.start_line ?? args.line_start ?? args.startLine ?? args.from_line; const startRaw = args.start_line ?? args.line_start ?? args.startLine ?? args.from_line;
@@ -34,19 +35,22 @@ export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null
const countRaw = args.line_count ?? args.count ?? args.num_lines; const countRaw = args.line_count ?? args.count ?? args.num_lines;
let lineRange: { start: number; end: number } | null = null; let lineRange: { start: number; end: number } | null = null;
const sNum = Number(startRaw); const sNum = Number(startRaw);
const eNum = Number(endRaw); const eNum = Number(endRaw);
if (startRaw != null && endRaw != null && Number.isFinite(sNum) && Number.isFinite(eNum)) { if (startRaw != null && endRaw != null && Number.isFinite(sNum) && Number.isFinite(eNum)) {
lineRange = { start: sNum, end: eNum }; lineRange = { end: eNum, start: sNum };
} else if (startRaw != null && countRaw != null) { } else if (startRaw != null && countRaw != null) {
const cNum = Number(countRaw); const cNum = Number(countRaw);
if (Number.isFinite(sNum) && Number.isFinite(cNum)) { if (Number.isFinite(sNum) && Number.isFinite(cNum)) {
lineRange = { start: sNum, end: sNum + cNum - 1 }; lineRange = { end: sNum + cNum - 1, start: sNum };
} }
} }
const fileType = getFileTypeByExtension(fileName); const fileType = getFileTypeByExtension(fileName);
const language = fileType ? fileType.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') : DEFAULT_LANGUAGE; const language = fileType ? fileType.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') : DEFAULT_LANGUAGE;
return { fileName, lineRange, language }; return { fileName, language, lineRange };
} }
@@ -5,9 +5,9 @@
// failure renders as a flat line beginning with `Error:`. Both shapes // failure renders as a flat line beginning with `Error:`. Both shapes
// are handled. // are handled.
import { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums'; import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/utils'; import type { AgenticSection } from '$lib/utils';
import { parseToolArgs } from './_shared';
export type RunJavascriptMeta = { export type RunJavascriptMeta = {
code: string; code: string;
@@ -17,30 +17,37 @@ export type RunJavascriptMeta = {
export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMeta | null { export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMeta | null {
const args = parseToolArgs(BuiltInTool.RUN_JAVASCRIPT, section); const args = parseToolArgs(BuiltInTool.RUN_JAVASCRIPT, section);
if (!args) return null; if (!args) return null;
const code = typeof args.code === 'string' ? args.code : ''; const code = typeof args.code === 'string' ? args.code : '';
if (!code) return null; if (!code) return null;
const timeoutRaw = Number(args.timeout_ms); const timeoutRaw = Number(args.timeout_ms);
const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : undefined; const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : undefined;
let errorMessage: string | undefined; let errorMessage: string | undefined;
const toolResultString = section.toolResult; const toolResultString = section.toolResult;
if (toolResultString) { if (toolResultString) {
// Branches matter here: a JSON object can carry `error`, but a // Branches matter here: a JSON object can carry `error`, but a
// JSON array always represents successful output (sandbox returns // JSON array always represents successful output (sandbox returns
// the array of values). Only when the result isn't a JSON object // the array of values). Only when the result isn't a JSON object
// do we scan raw lines for the `Error:` prefix. // do we scan raw lines for the `Error:` prefix.
let parsedObject: Record<string, unknown> | null = null; let parsedObject: Record<string, unknown> | null = null;
try { try {
const parsed: unknown = JSON.parse(toolResultString); const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsedObject = parsed as Record<string, unknown>; parsedObject = parsed as Record<string, unknown>;
} }
} catch { } catch {
parsedObject = null; parsedObject = null;
} }
if (typeof parsedObject?.error === 'string') { if (typeof parsedObject?.error === 'string') {
errorMessage = parsedObject.error; errorMessage = parsedObject.error;
} else if (!parsedObject) { } else if (!parsedObject) {
@@ -48,9 +55,10 @@ export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMe
.split('\n') .split('\n')
.map((line) => line.trim()) .map((line) => line.trim())
.find((line) => line.startsWith('Error:')); .find((line) => line.startsWith('Error:'));
if (errorLine) errorMessage = errorLine.slice('Error:'.length).trim(); if (errorLine) errorMessage = errorLine.slice('Error:'.length).trim();
} }
} }
return { code, timeoutMs, errorMessage }; return { code, errorMessage, timeoutMs };
} }
@@ -3,14 +3,14 @@
// finishes) and surfaces `bytes`, `result`, and `error` from the // finishes) and surfaces `bytes`, `result`, and `error` from the
// result blob. // result blob.
import { BuiltInTool } from '$lib/enums'; import { parseToolArgs } from './_shared';
import { import {
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
FILE_PATH_SEPARATOR_REGEX, FILE_PATH_SEPARATOR_REGEX,
TEXT_LANGUAGE_PREFIX_REGEX TEXT_LANGUAGE_PREFIX_REGEX
} from '$lib/constants'; } from '$lib/constants';
import { getFileTypeByExtension, tryParseToolResultObject, type AgenticSection } from '$lib/utils'; import { BuiltInTool } from '$lib/enums';
import { parseToolArgs } from './_shared'; import { type AgenticSection, getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
export type WriteFileMeta = { export type WriteFileMeta = {
fileName: string; fileName: string;
@@ -24,18 +24,19 @@ export type WriteFileMeta = {
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null { export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
const args = parseToolArgs(BuiltInTool.WRITE_FILE, section, { partial: true }); const args = parseToolArgs(BuiltInTool.WRITE_FILE, section, { partial: true });
if (!args) return null; if (!args) return null;
// Tool contracts drifted over time: some models emit `path`, // Tool contracts drifted over time: some models emit `path`,
// others `file_path` / `filePath`. Accept all three. // others `file_path` / `filePath`. Accept all three.
const rawPath = args.path ?? args.file_path ?? args.filePath; const rawPath = args.path ?? args.file_path ?? args.filePath;
if (typeof rawPath !== 'string' || !rawPath) return null; if (typeof rawPath !== 'string' || !rawPath) return null;
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
const content = typeof args.content === 'string' ? args.content : ''; const content = typeof args.content === 'string' ? args.content : '';
const language = const language =
getFileTypeByExtension(rawPath)?.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') ?? DEFAULT_LANGUAGE; getFileTypeByExtension(rawPath)?.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') ?? DEFAULT_LANGUAGE;
const resultObj = tryParseToolResultObject(section.toolResult); const resultObj = tryParseToolResultObject(section.toolResult);
const bytesWritten = const bytesWritten =
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined; resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
@@ -43,12 +44,12 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul
const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined; const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined;
return { return {
bytesWritten,
content,
errorMessage,
fileName, fileName,
filePath: rawPath, filePath: rawPath,
language, language,
content, resultMessage
bytesWritten,
resultMessage,
errorMessage
}; };
} }
@@ -6,9 +6,9 @@
ChatMessageUserBubble ChatMessageUserBubble
} from '$lib/components/app/chat'; } from '$lib/components/app/chat';
import { getMessageEditContext } from '$lib/contexts'; import { getMessageEditContext } from '$lib/contexts';
import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading } from '$lib/stores/chat.svelte'; import { isLoading } from '$lib/stores/chat.svelte';
import { MessageRole, ChatMessageStatisticsMode } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
interface Props { interface Props {
@@ -35,19 +35,19 @@
let { let {
class: className = '', class: className = '',
message,
siblingInfo = null,
deletionInfo, deletionInfo,
isLastUserMessage = false, isLastUserMessage = false,
message,
nextAssistantMessage = null, nextAssistantMessage = null,
showDeleteDialog,
onEdit,
onDelete,
onConfirmDelete, onConfirmDelete,
onCopy,
onDelete,
onEdit,
onForkConversation, onForkConversation,
onShowDeleteDialogChange,
onNavigateToSibling, onNavigateToSibling,
onCopy onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null
}: Props = $props(); }: Props = $props();
// Get contexts // Get contexts
@@ -60,13 +60,14 @@
// For agentic turns, prefer the cumulative agentic.llm totals over per-call timings. // For agentic turns, prefer the cumulative agentic.llm totals over per-call timings.
let storedReadingStats = $derived.by(() => { let storedReadingStats = $derived.by(() => {
const timings = nextAssistantMessage?.timings; const timings = nextAssistantMessage?.timings;
if (!timings?.prompt_n || !timings?.prompt_ms) return null; if (!timings?.prompt_n || !timings?.prompt_ms) return null;
const agentic = timings.agentic; const agentic = timings.agentic;
return { return {
promptTokens: agentic ? agentic.llm.prompt_n : timings.prompt_n, promptMs: agentic ? agentic.llm.prompt_ms : timings.prompt_ms,
promptMs: agentic ? agentic.llm.prompt_ms : timings.prompt_ms promptTokens: agentic ? agentic.llm.prompt_n : timings.prompt_n
}; };
}); });
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Card } from '$lib/components/ui/card';
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app'; import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
import { Card } from '$lib/components/ui/card';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database'; import type { DatabaseMessageExtra } from '$lib/types/database';
@@ -14,12 +14,12 @@
} }
let { let {
content,
attachments = [], attachments = [],
renderMarkdown = false,
textColorClass = 'text-foreground',
cardBgClass = 'dark:bg-primary/15', cardBgClass = 'dark:bg-primary/15',
maxHeightStyle = '' content,
maxHeightStyle = '',
renderMarkdown = false,
textColorClass = 'text-foreground'
}: Props = $props(); }: Props = $props();
let isMultiline = $state(false); let isMultiline = $state(false);
@@ -31,6 +31,7 @@
if (content.includes('\n')) { if (content.includes('\n')) {
isMultiline = true; isMultiline = true;
return; return;
} }
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { ArrowUp, Edit, Trash2 } from '@lucide/svelte'; import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte'; import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
interface Props { interface Props {
@@ -16,9 +16,9 @@
class: className = '', class: className = '',
content, content,
extras = [], extras = [],
onSendImmediately, onDelete,
onEdit, onEdit,
onDelete onSendImmediately
}: Props = $props(); }: Props = $props();
const editCtx = useMessageEditContext({ const editCtx = useMessageEditContext({
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes'; import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { Snippet, Component } from 'svelte'; import type { Component, Snippet } from 'svelte';
interface Props { interface Props {
icon: Component<{ class?: string }>; icon: Component<{ class?: string }>;
@@ -8,7 +8,7 @@
actions: Snippet; actions: Snippet;
} }
let { icon: IconComponent, message, actions }: Props = $props(); let { actions, icon: IconComponent, message }: Props = $props();
</script> </script>
<div class="my-2 rounded-lg border border-border bg-card p-3"> <div class="my-2 rounded-lg border border-border bg-card p-3">
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import ChatMessageActionCard from './ChatMessageActionCard.svelte';
import { RotateCw } from '@lucide/svelte'; import { RotateCw } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import ChatMessageActionCard from './ChatMessageActionCard.svelte';
interface Props { interface Props {
onDecision: (shouldContinue: boolean) => void; onDecision: (shouldContinue: boolean) => void;

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