diff --git a/apps/desktop/electron/backend-env.cjs b/apps/desktop/electron/backend-env.cjs
index 76329785be4..8b2e80ab1dd 100644
--- a/apps/desktop/electron/backend-env.cjs
+++ b/apps/desktop/electron/backend-env.cjs
@@ -61,10 +61,7 @@ function buildDesktopBackendPath({
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
- return appendUniquePathEntries(
- [hermesNodeBin, venvBin, currentPath, saneEntries],
- { delimiter }
- )
+ return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
diff --git a/apps/desktop/electron/backend-env.test.cjs b/apps/desktop/electron/backend-env.test.cjs
index 75e0c79d5d6..756740a7337 100644
--- a/apps/desktop/electron/backend-env.test.cjs
+++ b/apps/desktop/electron/backend-env.test.cjs
@@ -76,10 +76,7 @@ test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root'
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
'C:\\Users\\test\\AppData\\Local\\hermes'
)
- assert.equal(
- normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
- '/Users/test/.hermes'
- )
+ assert.equal(normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }), '/Users/test/.hermes')
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
@@ -104,8 +101,5 @@ test('Windows PATH casing and delimiter are preserved without POSIX sane entries
})
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
- assert.equal(
- appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
- '/a:/b:/c'
- )
+ assert.equal(appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), '/a:/b:/c')
})
diff --git a/apps/desktop/electron/backend-ready.cjs b/apps/desktop/electron/backend-ready.cjs
index 68556f6bcbc..016572bec91 100644
--- a/apps/desktop/electron/backend-ready.cjs
+++ b/apps/desktop/electron/backend-ready.cjs
@@ -167,5 +167,5 @@ module.exports = {
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
- MIN_PORT_ANNOUNCE_TIMEOUT_MS,
+ MIN_PORT_ANNOUNCE_TIMEOUT_MS
}
diff --git a/apps/desktop/electron/backend-ready.test.cjs b/apps/desktop/electron/backend-ready.test.cjs
index 2252888096c..2792baf371a 100644
--- a/apps/desktop/electron/backend-ready.test.cjs
+++ b/apps/desktop/electron/backend-ready.test.cjs
@@ -25,7 +25,7 @@ const {
waitForDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
- MIN_PORT_ANNOUNCE_TIMEOUT_MS,
+ MIN_PORT_ANNOUNCE_TIMEOUT_MS
} = require('./backend-ready.cjs')
// A minimal stand-in for a spawned child process: an EventEmitter with a
diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs
index 644f9405056..a0a2ff9070d 100644
--- a/apps/desktop/electron/bootstrap-runner.cjs
+++ b/apps/desktop/electron/bootstrap-runner.cjs
@@ -179,7 +179,13 @@ function downloadInstallScript(commit, destPath) {
})
}
-async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit, _download = downloadInstallScript }) {
+async function resolveInstallScript({
+ installStamp,
+ sourceRepoRoot,
+ hermesHome,
+ emit,
+ _download = downloadInstallScript
+}) {
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
// of APP_ROOT/../..).
@@ -293,15 +299,19 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
- const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
- stdio: ['ignore', 'pipe', 'pipe'],
- env: {
- ...process.env,
- // Pass HERMES_HOME through so install.ps1 respects the caller's
- // choice rather than re-computing the default.
- HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
- }
- }))
+ const child = spawn(
+ ps,
+ fullArgs,
+ hiddenWindowsChildOptions({
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: {
+ ...process.env,
+ // Pass HERMES_HOME through so install.ps1 respects the caller's
+ // choice rather than re-computing the default.
+ HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
+ }
+ })
+ )
let stdout = ''
let stderr = ''
diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs
index f9eaaa65e9e..12f7859640d 100644
--- a/apps/desktop/electron/connection-config.cjs
+++ b/apps/desktop/electron/connection-config.cjs
@@ -261,12 +261,7 @@ function cookiesHaveSession(cookies) {
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
- return cookies.some(
- c =>
- c &&
- c.value &&
- (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
- )
+ return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
}
module.exports = {
diff --git a/apps/desktop/electron/desktop-uninstall.cjs b/apps/desktop/electron/desktop-uninstall.cjs
index 41360df2612..01b756acd1e 100644
--- a/apps/desktop/electron/desktop-uninstall.cjs
+++ b/apps/desktop/electron/desktop-uninstall.cjs
@@ -138,10 +138,7 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
- lines.push(
- `cd ${q(agentRoot)} 2>/dev/null || true`,
- `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`
- )
+ lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
@@ -169,7 +166,15 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
* Removal: even after the desktop PID is gone, Windows releases directory
* handles lazily, so a single `rmdir /s /q` can half-fail — retry up to 10x.
*/
-function buildWindowsCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
+function buildWindowsCleanupScript({
+ desktopPid,
+ pythonExe,
+ pythonPath,
+ agentRoot,
+ uninstallArgs,
+ appPath,
+ hermesHome
+}) {
const pid = Number(desktopPid) || 0
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
diff --git a/apps/desktop/electron/desktop-uninstall.test.cjs b/apps/desktop/electron/desktop-uninstall.test.cjs
index b6e5a386ff8..15a864b7c4f 100644
--- a/apps/desktop/electron/desktop-uninstall.test.cjs
+++ b/apps/desktop/electron/desktop-uninstall.test.cjs
@@ -101,10 +101,7 @@ test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
})
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
- assert.equal(
- resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}),
- '/opt/hermes/linux-unpacked'
- )
+ assert.equal(resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}), '/opt/hermes/linux-unpacked')
// A system-package install (/usr/bin) → null, left to apt/dnf.
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
})
diff --git a/apps/desktop/electron/fs-read-dir.cjs b/apps/desktop/electron/fs-read-dir.cjs
index 52d182ad567..1a2a00313b5 100644
--- a/apps/desktop/electron/fs-read-dir.cjs
+++ b/apps/desktop/electron/fs-read-dir.cjs
@@ -92,9 +92,7 @@ async function readDirForIpc(dirPath, options = {}) {
try {
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
- const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
- entryForDirent(dirent, resolved, fsImpl)
- )
+ const entries = await mapWithStatConcurrency(visibleDirents, dirent => entryForDirent(dirent, resolved, fsImpl))
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
diff --git a/apps/desktop/electron/fs-read-dir.test.cjs b/apps/desktop/electron/fs-read-dir.test.cjs
index 42e80af3489..558ec95b539 100644
--- a/apps/desktop/electron/fs-read-dir.test.cjs
+++ b/apps/desktop/electron/fs-read-dir.test.cjs
@@ -349,7 +349,10 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
assert.equal(result.error, undefined)
assert.equal(result.entries.length, names.length)
assert.equal(statCalls.length, names.length)
- assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
+ assert.equal(
+ statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)),
+ false
+ )
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
assert.deepEqual(
@@ -357,8 +360,5 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
expectedNames
)
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
- assert.equal(
- result.entries.filter(entry => entry.isDirectory).length,
- successfulDirectoryNames.size
- )
+ assert.equal(result.entries.filter(entry => entry.isDirectory).length, successfulDirectoryNames.size)
})
diff --git a/apps/desktop/electron/git-repo-scan.cjs b/apps/desktop/electron/git-repo-scan.cjs
index 7b56eed40c2..f7617b76b70 100644
--- a/apps/desktop/electron/git-repo-scan.cjs
+++ b/apps/desktop/electron/git-repo-scan.cjs
@@ -86,10 +86,8 @@ async function scanGitRepos(roots, options = {}) {
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
}
- await mapLimit(
- searchRoots.map(root => String(root || '').trim()).filter(Boolean),
- MAX_CONCURRENCY,
- root => walk(root, 0)
+ await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
+ walk(root, 0)
)
return [...found.entries()].map(([root, label]) => ({ label, root }))
diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.cjs
index 19b4aecf92d..28f5fc7f955 100644
--- a/apps/desktop/electron/git-review-ops.cjs
+++ b/apps/desktop/electron/git-review-ops.cjs
@@ -188,7 +188,12 @@ async function defaultBranchName(git) {
// Prefer a local trunk, then a remote-only one (returns the clean name either
// way) so "branch off main" works even before main is checked out locally.
- for (const ref of ['refs/heads/main', 'refs/heads/master', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) {
+ for (const ref of [
+ 'refs/heads/main',
+ 'refs/heads/master',
+ 'refs/remotes/origin/main',
+ 'refs/remotes/origin/master'
+ ]) {
try {
await git.raw(['rev-parse', '--verify', '--quiet', ref])
diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.cjs
index 486686e4e4a..de4e01cfb94 100644
--- a/apps/desktop/electron/git-worktree-ops.cjs
+++ b/apps/desktop/electron/git-worktree-ops.cjs
@@ -45,7 +45,10 @@ function parseWorktrees(out) {
} else if (!cur) {
continue
} else if (line.startsWith('branch ')) {
- cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '')
+ cur.branch = line
+ .slice(7)
+ .trim()
+ .replace(/^refs\/heads\//, '')
} else if (line === 'detached') {
cur.detached = true
} else if (line === 'bare') {
@@ -122,10 +125,9 @@ async function gitLine(gitBin, args, cwd) {
}
async function defaultBranch(gitBin, cwd) {
- const remote = (await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)).replace(
- /^origin\//,
- ''
- )
+ const remote = (
+ await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)
+ ).replace(/^origin\//, '')
if (remote) {
return remote
@@ -177,7 +179,16 @@ async function ensureGitRepo(gitBin, dir) {
// Inline identity so the seed commit lands even with no global git config.
await runGit(
gitBin,
- ['-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'Initial commit'],
+ [
+ '-c',
+ 'user.email=hermes@localhost',
+ '-c',
+ 'user.name=Hermes',
+ 'commit',
+ '--allow-empty',
+ '-m',
+ 'Initial commit'
+ ],
dir
)
}
diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.cjs
index 7b568ec3d11..574e659f96c 100644
--- a/apps/desktop/electron/hardening.cjs
+++ b/apps/desktop/electron/hardening.cjs
@@ -186,7 +186,10 @@ async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
}
- throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
+ throw ipcPathError(
+ code || 'read-error',
+ `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
+ )
}
}
@@ -201,7 +204,10 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
return realPath
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
- throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
+ throw ipcPathError(
+ code || 'read-error',
+ `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
+ )
}
}
diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index 9974ead119a..d7184bc9743 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -21,7 +21,6 @@ const crypto = require('node:crypto')
const fs = require('node:fs')
const http = require('node:http')
const https = require('node:https')
-const net = require('node:net')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
@@ -330,9 +329,7 @@ function hermesManagedNodePathEntries() {
}
function pathWithHermesManagedNode(...entries) {
- return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH]
- .filter(Boolean)
- .join(path.delimiter)
+ return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH].filter(Boolean).join(path.delimiter)
}
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
@@ -1325,10 +1322,7 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
bootstrap: false,
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
- pythonPathEntries: [
- ...(directoryExists(root) ? [root] : []),
- ...getVenvSitePackagesEntries(venvRoot)
- ],
+ pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
venvRoot
}),
kind: 'python',
@@ -1604,9 +1598,7 @@ function applyWindowsNoConsoleSpawnHints(backend) {
const usesHermesModule =
backend.kind === 'python' ||
- (Array.isArray(backend.args) &&
- backend.args[0] === '-m' &&
- backend.args[1] === 'hermes_cli.main')
+ (Array.isArray(backend.args) && backend.args[0] === '-m' && backend.args[1] === 'hermes_cli.main')
if (!usesHermesModule) return backend
@@ -2182,7 +2174,8 @@ async function applyUpdates(opts = {}) {
emitUpdateProgress({
stage: 'restart',
- message: 'Updating Hermes — this window will close and the updater will open. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.',
+ message:
+ 'Updating Hermes — this window will close and the updater will open. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.',
percent: 100
})
repairMacUpdaterHelper(updater)
@@ -2265,7 +2258,9 @@ async function handOffWindowsBootstrapRecovery(reason) {
})
child.unref()
- rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
+ rememberLog(
+ `[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`
+ )
// Same dwell as the in-app update hand-off (#50419): give the updater's
// window time to appear before we vanish, so the recovery doesn't look like
// a crash and provoke a mid-recovery relaunch.
@@ -2792,8 +2787,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
const venvRoot = path.join(root, 'venv')
const venvPython = getVenvPython(venvRoot)
- const command =
- IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
+ const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
@@ -2817,9 +2811,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
// ensureRuntime() to create / refresh it before launch.
function createActiveBackend(dashboardArgs) {
const venvPython = getVenvPython(VENV_ROOT)
- const command = fileExists(venvPython)
- ? getNoConsoleVenvPython(VENV_ROOT)
- : toNoConsolePython(findSystemPython())
+ const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython())
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
@@ -2909,15 +2901,17 @@ function resolveHermesBackend(dashboardArgs) {
// and lets the resolver fall through to step 6 / bootstrap.
const shellForProbe = isCommandScript(hermesCommand)
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
- return unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
- label: `existing Hermes CLI at ${hermesCommand}`,
- command: hermesCommand,
- args: dashboardArgs,
- bootstrap: false,
- env: {},
- kind: 'command',
- shell: shellForProbe
- }
+ return (
+ unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
+ label: `existing Hermes CLI at ${hermesCommand}`,
+ command: hermesCommand,
+ args: dashboardArgs,
+ bootstrap: false,
+ env: {},
+ kind: 'command',
+ shell: shellForProbe
+ }
+ )
}
rememberLog(
`Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.`
@@ -2997,7 +2991,9 @@ async function ensureRuntime(backend) {
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
- const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
+ const handoffError = new Error(
+ 'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.'
+ )
handoffError.isBootstrapFailure = true
handoffError.bootstrapHandedOff = true
bootstrapFailure = handoffError
@@ -5512,7 +5508,10 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
// Discover the ephemeral port the child bound to
- const port = await Promise.race([waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed])
+ const port = await Promise.race([
+ waitForDashboardPortAnnouncement(hermesProcess, { readyFile }),
+ backendStartFailed
+ ])
if (readyFile) {
fs.unlink(readyFile, () => {})
}
@@ -6932,9 +6931,7 @@ ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
-ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) =>
- listWorktrees(repoPath, resolveGitBinary())
-)
+ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
@@ -6948,9 +6945,7 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
-ipcMain.handle('hermes:git:branchList', async (_event, repoPath) =>
- listBranches(repoPath, resolveGitBinary())
-)
+ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
diff --git a/apps/desktop/electron/oauth-net-request.test.cjs b/apps/desktop/electron/oauth-net-request.test.cjs
index 7d53bde5092..63a27f6219a 100644
--- a/apps/desktop/electron/oauth-net-request.test.cjs
+++ b/apps/desktop/electron/oauth-net-request.test.cjs
@@ -30,5 +30,8 @@ test('setJsonRequestHeaders does not set Electron-restricted Content-Length', ()
setJsonRequestHeaders(request)
assert.deepEqual(headers, [['Content-Type', 'application/json']])
- assert.equal(headers.some(([name]) => name.toLowerCase() === 'content-length'), false)
+ assert.equal(
+ headers.some(([name]) => name.toLowerCase() === 'content-length'),
+ false
+ )
})
diff --git a/apps/desktop/electron/update-count.test.cjs b/apps/desktop/electron/update-count.test.cjs
index 69ee99aa616..fdac4fd744a 100644
--- a/apps/desktop/electron/update-count.test.cjs
+++ b/apps/desktop/electron/update-count.test.cjs
@@ -7,45 +7,81 @@ const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
- assert.equal(resolveBehindCount({
- countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',
- isShallow: true, hasMergeBase: false,
- }), 1)
+ assert.equal(
+ resolveBehindCount({
+ countStr: '12104',
+ currentSha: 'aaa',
+ targetSha: 'bbb',
+ isShallow: true,
+ hasMergeBase: false
+ }),
+ 1
+ )
})
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
- assert.equal(resolveBehindCount({
- countStr: '12104', currentSha: 'abc', targetSha: 'abc',
- isShallow: true, hasMergeBase: false,
- }), 0)
+ assert.equal(
+ resolveBehindCount({
+ countStr: '12104',
+ currentSha: 'abc',
+ targetSha: 'abc',
+ isShallow: true,
+ hasMergeBase: false
+ }),
+ 0
+ )
})
test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {
- assert.equal(resolveBehindCount({
- countStr: '3', currentSha: 'aaa', targetSha: 'bbb',
- isShallow: true, hasMergeBase: true,
- }), 3)
+ assert.equal(
+ resolveBehindCount({
+ countStr: '3',
+ currentSha: 'aaa',
+ targetSha: 'bbb',
+ isShallow: true,
+ hasMergeBase: true
+ }),
+ 3
+ )
})
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
- assert.equal(resolveBehindCount({
- countStr: '7', currentSha: 'aaa', targetSha: 'bbb',
- isShallow: false, hasMergeBase: true,
- }), 7)
+ assert.equal(
+ resolveBehindCount({
+ countStr: '7',
+ currentSha: 'aaa',
+ targetSha: 'bbb',
+ isShallow: false,
+ hasMergeBase: true
+ }),
+ 7
+ )
})
test('up-to-date full clone reports 0', () => {
- assert.equal(resolveBehindCount({
- countStr: '0', currentSha: 'x', targetSha: 'x',
- isShallow: false, hasMergeBase: true,
- }), 0)
+ assert.equal(
+ resolveBehindCount({
+ countStr: '0',
+ currentSha: 'x',
+ targetSha: 'x',
+ isShallow: false,
+ hasMergeBase: true
+ }),
+ 0
+ )
})
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
- assert.equal(resolveBehindCount({
- countStr: '', currentSha: 'aaa', targetSha: 'bbb',
- isShallow: false, hasMergeBase: true,
- }), 0)
+ assert.equal(
+ resolveBehindCount({
+ countStr: '',
+ currentSha: 'aaa',
+ targetSha: 'bbb',
+ isShallow: false,
+ hasMergeBase: true
+ }),
+ 0
+ )
})
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
@@ -68,12 +104,24 @@ test('full (non-shallow) clone always runs the count', () => {
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
// it and must fall through to the SHA compare (mirrors the live call site).
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
- assert.equal(resolveBehindCount({
- countStr: '', currentSha: 'aaa', targetSha: 'bbb',
- isShallow: true, hasMergeBase: false,
- }), 1)
- assert.equal(resolveBehindCount({
- countStr: '', currentSha: 'same', targetSha: 'same',
- isShallow: true, hasMergeBase: false,
- }), 0)
+ assert.equal(
+ resolveBehindCount({
+ countStr: '',
+ currentSha: 'aaa',
+ targetSha: 'bbb',
+ isShallow: true,
+ hasMergeBase: false
+ }),
+ 1
+ )
+ assert.equal(
+ resolveBehindCount({
+ countStr: '',
+ currentSha: 'same',
+ targetSha: 'same',
+ isShallow: true,
+ hasMergeBase: false
+ }),
+ 0
+ )
})
diff --git a/apps/desktop/electron/update-relaunch.test.cjs b/apps/desktop/electron/update-relaunch.test.cjs
index 0cccb1b20eb..de0a76efeec 100644
--- a/apps/desktop/electron/update-relaunch.test.cjs
+++ b/apps/desktop/electron/update-relaunch.test.cjs
@@ -62,7 +62,10 @@ test('resolveUnpackedRelease is null for AppImage / .deb / .rpm / dev / unresolv
assert.equal(resolveUnpackedRelease('/usr/lib/hermes/hermes', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease('/opt/Hermes/hermes', ROOT, 'linux'), null)
// dev electron
- assert.equal(resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'), null)
+ assert.equal(
+ resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'),
+ null
+ )
// empty / missing
assert.equal(resolveUnpackedRelease('', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease(path.join(UNPACKED, 'hermes'), '', 'linux'), null)
diff --git a/apps/desktop/electron/update-remote.cjs b/apps/desktop/electron/update-remote.cjs
index 3cb432d1b1e..1e99bbe8877 100644
--- a/apps/desktop/electron/update-remote.cjs
+++ b/apps/desktop/electron/update-remote.cjs
@@ -39,7 +39,9 @@ function canonicalGitHubRemote(url) {
}
function isSshRemote(url) {
- const value = String(url || '').trim().toLowerCase()
+ const value = String(url || '')
+ .trim()
+ .toLowerCase()
return value.startsWith('git@') || value.startsWith('ssh://')
}
diff --git a/apps/desktop/electron/vscode-marketplace.cjs b/apps/desktop/electron/vscode-marketplace.cjs
index 829182a1f0f..55e49bc30ec 100644
--- a/apps/desktop/electron/vscode-marketplace.cjs
+++ b/apps/desktop/electron/vscode-marketplace.cjs
@@ -26,7 +26,11 @@ const REQUEST_TIMEOUT_MS = 20_000
const ID_RE = /^[\w-]+\.[\w-]+$/
/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
-function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
+function request(
+ url,
+ { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {},
+ redirectsLeft = MAX_REDIRECTS
+) {
return new Promise((resolve, reject) => {
const req = https.request(url, { method, headers }, res => {
const status = res.statusCode ?? 0
@@ -42,7 +46,13 @@ function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MA
const next = new URL(res.headers.location, url).toString()
res.resume()
// Redirects to the CDN are plain GETs (drop the POST body).
- resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
+ resolve(
+ request(
+ next,
+ { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes },
+ redirectsLeft - 1
+ )
+ )
return
}
diff --git a/apps/desktop/electron/window-state.test.cjs b/apps/desktop/electron/window-state.test.cjs
index 2f3ea6ca52a..a0f68ce333c 100644
--- a/apps/desktop/electron/window-state.test.cjs
+++ b/apps/desktop/electron/window-state.test.cjs
@@ -26,7 +26,16 @@ const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
// ─── sanitizeWindowState ───────────────────────────────────────────────────
test('sanitizeWindowState rejects missing/garbage input', () => {
- for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
+ for (const bad of [
+ null,
+ undefined,
+ 'nope',
+ 42,
+ {},
+ { width: 'x', height: 800 },
+ { width: NaN, height: 800 },
+ { width: 1000 }
+ ]) {
assert.equal(sanitizeWindowState(bad), null)
}
})
@@ -112,9 +121,13 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
test('debounce coalesces a burst into one trailing run', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
- const d = debounce(() => { calls += 1 }, 250)
+ const d = debounce(() => {
+ calls += 1
+ }, 250)
- d(); d(); d()
+ d()
+ d()
+ d()
assert.equal(calls, 0)
t.mock.timers.tick(249)
assert.equal(calls, 0)
@@ -125,7 +138,9 @@ test('debounce coalesces a burst into one trailing run', t => {
test('debounce.flush runs now and cancels the pending timer', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
- const d = debounce(() => { calls += 1 }, 250)
+ const d = debounce(() => {
+ calls += 1
+ }, 250)
d()
d.flush()
diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs
index 383f2f2d3d0..0194464d641 100644
--- a/apps/desktop/electron/windows-child-process.test.cjs
+++ b/apps/desktop/electron/windows-child-process.test.cjs
@@ -13,7 +13,7 @@ function readElectronFile(name) {
function requireHiddenChildOptions(source, needle) {
const match = needle instanceof RegExp ? needle.exec(source) : null
- const index = needle instanceof RegExp ? match?.index ?? -1 : source.indexOf(needle)
+ const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
diff --git a/apps/desktop/electron/windows-user-env.cjs b/apps/desktop/electron/windows-user-env.cjs
index 0ba93d339aa..4bfaba1570d 100644
--- a/apps/desktop/electron/windows-user-env.cjs
+++ b/apps/desktop/electron/windows-user-env.cjs
@@ -21,8 +21,7 @@ const { execFileSync } = require('node:child_process')
// the requested value line isn't present.
function parseRegQueryValue(stdout, name) {
if (!stdout || !name) return null
- const typePattern =
- /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
+ const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(typePattern)
@@ -47,10 +46,7 @@ function expandWindowsEnvRefs(value, env = process.env) {
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty.
-function readWindowsUserEnvVar(
- name,
- { platform = process.platform, env = process.env, exec = execFileSync } = {}
-) {
+function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) {
if (platform !== 'win32' || !name) return null
let stdout
try {
diff --git a/apps/desktop/electron/windows-user-env.test.cjs b/apps/desktop/electron/windows-user-env.test.cjs
index dcc71d2c95b..3fee1598190 100644
--- a/apps/desktop/electron/windows-user-env.test.cjs
+++ b/apps/desktop/electron/windows-user-env.test.cjs
@@ -1,21 +1,12 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
-const {
- expandWindowsEnvRefs,
- parseRegQueryValue,
- readWindowsUserEnvVar
-} = require('./windows-user-env.cjs')
+const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs')
// ── parseRegQueryValue ─────────────────────────────────────────────────────
test('parseRegQueryValue extracts a REG_SZ value', () => {
- const out = [
- '',
- 'HKEY_CURRENT_USER\\Environment',
- ' HERMES_HOME REG_SZ F:\\Hermes\\data',
- ''
- ].join('\r\n')
+ const out = ['', 'HKEY_CURRENT_USER\\Environment', ' HERMES_HOME REG_SZ F:\\Hermes\\data', ''].join('\r\n')
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
})
@@ -39,10 +30,7 @@ test('parseRegQueryValue returns null when the value line is absent', () => {
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
- assert.equal(
- expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
- 'C:\\Users\\jeff\\h'
- )
+ assert.equal(expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }), 'C:\\Users\\jeff\\h')
})
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {
diff --git a/apps/desktop/electron/workspace-cwd.cjs b/apps/desktop/electron/workspace-cwd.cjs
index 2955975b0b0..bb5da777148 100644
--- a/apps/desktop/electron/workspace-cwd.cjs
+++ b/apps/desktop/electron/workspace-cwd.cjs
@@ -14,11 +14,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
return false
}
- const roots = new Set(
- (installRoots ?? [])
- .filter(Boolean)
- .map(candidate => path.resolve(String(candidate)))
- )
+ const roots = new Set((installRoots ?? []).filter(Boolean).map(candidate => path.resolve(String(candidate))))
for (const root of roots) {
if (resolved === root) {
diff --git a/apps/desktop/electron/workspace-cwd.test.cjs b/apps/desktop/electron/workspace-cwd.test.cjs
index 760fb9d08ef..85a044ab3be 100644
--- a/apps/desktop/electron/workspace-cwd.test.cjs
+++ b/apps/desktop/electron/workspace-cwd.test.cjs
@@ -13,33 +13,21 @@ const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
const installRoot = path.resolve('/opt/Hermes')
test('isPackagedInstallPath returns false when not packaged', () => {
- assert.equal(
- isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
- false
- )
+ assert.equal(isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }), false)
})
test('isPackagedInstallPath flags the install root itself', () => {
- assert.equal(
- isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
- true
- )
+ assert.equal(isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }), true)
})
test('isPackagedInstallPath flags paths nested under the install root', () => {
const nested = path.join(installRoot, 'resources', 'app.asar')
- assert.equal(
- isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
- true
- )
+ assert.equal(isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }), true)
})
test('isPackagedInstallPath ignores paths outside the install root', () => {
const homeProject = path.resolve('/home/user/projects/demo')
- assert.equal(
- isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
- false
- )
+ assert.equal(isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }), false)
})
diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx
index ed31a007bd5..8f6c2349f83 100644
--- a/apps/desktop/src/app/agents/index.tsx
+++ b/apps/desktop/src/app/agents/index.tsx
@@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
-import { FadeText } from '@/components/ui/fade-text'
import { Codicon } from '@/components/ui/codicon'
+import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx
index b4dfd994e9f..d76cc2baee4 100644
--- a/apps/desktop/src/app/artifacts/index.tsx
+++ b/apps/desktop/src/app/artifacts/index.tsx
@@ -477,17 +477,20 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}, [artifacts])
- const openArtifact = useCallback(async (href: string) => {
- try {
- if (window.hermesDesktop?.openExternal) {
- await window.hermesDesktop.openExternal(href)
- } else {
- window.open(href, '_blank', 'noopener,noreferrer')
+ const openArtifact = useCallback(
+ async (href: string) => {
+ try {
+ if (window.hermesDesktop?.openExternal) {
+ await window.hermesDesktop.openExternal(href)
+ } else {
+ window.open(href, '_blank', 'noopener,noreferrer')
+ }
+ } catch (err) {
+ notifyError(err, a.openFailed)
}
- } catch (err) {
- notifyError(err, a.openFailed)
- }
- }, [a])
+ },
+ [a]
+ )
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
@@ -839,7 +842,8 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{
Cell: PrimaryCell,
bodyClassName: 'p-0',
- header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
+ header: (filter, a) =>
+ filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault,
id: 'primary',
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
},
diff --git a/apps/desktop/src/app/chat/composer/attachments.test.tsx b/apps/desktop/src/app/chat/composer/attachments.test.tsx
index c31e5612f35..0ea85811315 100644
--- a/apps/desktop/src/app/chat/composer/attachments.test.tsx
+++ b/apps/desktop/src/app/chat/composer/attachments.test.tsx
@@ -2,9 +2,9 @@ import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { I18nProvider } from '@/i18n/context'
+import type { ComposerAttachment } from '@/store/composer'
import { AttachmentList } from './attachments'
-import type { ComposerAttachment } from '@/store/composer'
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
return { id, kind: 'file', label }
@@ -32,7 +32,10 @@ describe('AttachmentList', () => {
it('renders empty list without error', () => {
renderWithI18n()
- const container = screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
+
+ const container =
+ screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
+
expect(container).toBeDefined()
})
@@ -55,10 +58,7 @@ describe('AttachmentList', () => {
})
it('does not crash when attachments array contains null entries', () => {
- const attachments = [
- null as unknown as ComposerAttachment,
- makeAttachment('a', 'valid.txt')
- ]
+ const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
expect(() => {
renderWithI18n()
diff --git a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx
index 921ec485ae3..ff01bf6fd37 100644
--- a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx
+++ b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx
@@ -59,8 +59,10 @@ function Harness({
}
const editor = editorRef.current
+
if (editor) {
const domText = composerPlainText(editor)
+
if (domText !== draftRef.current) {
draftRef.current = domText
setDraft(domText)
@@ -127,9 +129,11 @@ function Harness({
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
const onSubmit = vi.fn()
+
const { getByTestId } = render(
)
+
const editor = getByTestId('editor')
// Fast typing: the DOM has the text but NO input event fired, so `draft`
@@ -146,9 +150,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
const onQueue = vi.fn()
const onDrain = vi.fn()
const onCancel = vi.fn()
+
const { getByTestId } = render(
)
+
const editor = getByTestId('editor')
await act(async () => {
@@ -165,9 +171,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
+
const { getByTestId } = render(
)
+
const editor = getByTestId('editor')
await act(async () => {
@@ -183,9 +191,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
const onDrain = vi.fn()
const onSubmit = vi.fn()
+
const { getByTestId } = render(
)
+
const editor = getByTestId('editor')
await act(async () => {
@@ -200,9 +210,18 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
const onSubmit = vi.fn()
const onDrain = vi.fn()
+
const { getByTestId } = render(
-
+
)
+
const editor = getByTestId('editor')
await act(async () => {
diff --git a/apps/desktop/src/app/chat/composer/help-hint.tsx b/apps/desktop/src/app/chat/composer/help-hint.tsx
index ea213e462f2..8c0546ace3f 100644
--- a/apps/desktop/src/app/chat/composer/help-hint.tsx
+++ b/apps/desktop/src/app/chat/composer/help-hint.tsx
@@ -33,7 +33,7 @@ export function HelpHint() {
{COMPOSER_HOTKEY_ROWS.map(row => (
-
+
))}
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
index 8823084a36e..5389d9f4d57 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts
@@ -59,7 +59,11 @@ function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
return new Error(copy.microphoneStartFailed)
}
-export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorderHandle; level: number; recording: boolean } {
+export function useMicRecorder(copy: MicRecorderErrorCopy): {
+ handle: MicRecorderHandle
+ level: number
+ recording: boolean
+} {
const [level, setLevel] = useState(0)
const [recording, setRecording] = useState(false)
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
index 38feb50d9ae..0b71507bfd1 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts
@@ -1,19 +1,12 @@
-import {
- type PointerEvent as ReactPointerEvent,
- type RefObject,
- useCallback,
- useEffect,
- useRef,
- useState
-} from 'react'
+import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import {
POPOUT_ESTIMATED_HEIGHT,
POPOUT_WIDTH_REM,
- readPopoutBounds,
- setComposerPopoutPosition,
type PopoutPosition,
- type PopoutSize
+ type PopoutSize,
+ readPopoutBounds,
+ setComposerPopoutPosition
} from '@/store/composer-popout'
// Floating surface long-press before it becomes draggable (the 5px platform drags
@@ -80,6 +73,7 @@ function dockProximityOf(rect: DOMRect) {
const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom
const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX)
+
const h =
horizontalDist <= DOCK_ZONE_CENTER_TOLERANCE_PX
? 1
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts
index b0bac82825c..1e3e48c1566 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts
@@ -98,12 +98,14 @@ export function useSlashCompletions(options: {
const matches = (
needle
- ? $sessions.get().filter(
- session =>
- sessionTitle(session).toLowerCase().includes(needle) ||
- (session.preview ?? '').toLowerCase().includes(needle) ||
- session.id.toLowerCase().includes(needle)
- )
+ ? $sessions
+ .get()
+ .filter(
+ session =>
+ sessionTitle(session).toLowerCase().includes(needle) ||
+ (session.preview ?? '').toLowerCase().includes(needle) ||
+ session.id.toLowerCase().includes(needle)
+ )
: $sessions.get()
).slice(0, SESSION_INLINE_LIMIT)
@@ -135,9 +137,7 @@ export function useSlashCompletions(options: {
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
// backend didn't categorize.
- const sections = catalog.categories?.length
- ? catalog.categories
- : [{ name: '', pairs: catalog.pairs ?? [] }]
+ const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }]
const items = sections.flatMap(section =>
section.pairs.map(([command, meta]) => ({
@@ -151,10 +151,9 @@ export function useSlashCompletions(options: {
return { items, query }
}
- const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
- 'complete.slash',
- { text }
- )
+ const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', {
+ text
+ })
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
index e4e8f3201be..a8725cac666 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts
@@ -220,22 +220,25 @@ export function useVoiceConversation({
}
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
- const speak = useCallback(async (text: string) => {
- setStatus('speaking')
+ const speak = useCallback(
+ async (text: string) => {
+ setStatus('speaking')
- try {
- await playSpeechText(text, { source: 'voice-conversation' })
- } catch (error) {
- notifyError(error, voiceCopy.playbackFailed)
- } finally {
- if (enabledRef.current) {
- pendingStartRef.current = true
- setStatus('idle')
- } else {
- setStatus('idle')
+ try {
+ await playSpeechText(text, { source: 'voice-conversation' })
+ } catch (error) {
+ notifyError(error, voiceCopy.playbackFailed)
+ } finally {
+ if (enabledRef.current) {
+ pendingStartRef.current = true
+ setStatus('idle')
+ } else {
+ setStatus('idle')
+ }
}
- }
- }, [voiceCopy.playbackFailed])
+ },
+ [voiceCopy.playbackFailed]
+ )
const start = useCallback(async () => {
if (!onTranscribeAudio) {
@@ -255,7 +258,14 @@ export function useVoiceConversation({
consumePendingResponse()
pendingStartRef.current = true
await startListening()
- }, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening, voiceCopy.configureSpeechToText, voiceCopy.unavailable])
+ }, [
+ consumePendingResponse,
+ onFatalError,
+ onTranscribeAudio,
+ startListening,
+ voiceCopy.configureSpeechToText,
+ voiceCopy.unavailable
+ ])
const end = useCallback(async () => {
pendingStartRef.current = false
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index cca8f00638f..890ba02840c 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -278,14 +278,17 @@ export function ChatBar({
poppedOut ? handleComposerDock() : handleComposerPopOut()
}, [handleComposerDock, handleComposerPopOut, poppedOut])
- const { dockProximity, dragging, onPointerDown: onComposerGesturePointerDown } =
- useComposerPopoutGestures({
- composerRef,
- onDock: handleComposerDock,
- onPopOut: handleComposerPopOut,
- poppedOut,
- position: popoutPosition
- })
+ const {
+ dockProximity,
+ dragging,
+ onPointerDown: onComposerGesturePointerDown
+ } = useComposerPopoutGestures({
+ composerRef,
+ onDock: handleComposerDock,
+ onPopOut: handleComposerPopOut,
+ poppedOut,
+ position: popoutPosition
+ })
const draftRef = useRef(draft)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
@@ -826,8 +829,7 @@ export function ChatBar({
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on
// Space/Tab — neither should dead-end on a popover.
- const argStageEmpty =
- trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
+ const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const closeTrigger = () => {
setTrigger(null)
@@ -854,7 +856,14 @@ export function ChatBar({
id: text,
type: 'slash',
label: text.slice(1),
- metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
+ metadata: {
+ command: slashCommandToken(trigger.query),
+ display: text,
+ meta: '',
+ group: '',
+ action: '',
+ rawText: text
+ }
})
}
@@ -994,10 +1003,7 @@ export function ChatBar({
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
- if (
- (event.key === 'Backspace' || event.key === 'Delete') &&
- deleteSelectionInEditor(event.currentTarget)
- ) {
+ if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) {
event.preventDefault()
flushEditorToDraft(event.currentTarget)
@@ -1771,12 +1777,14 @@ export function ChatBar({
// open — Esc must close that overlay, never double as canceling the stream
// behind it. A latest-handler ref keeps the listener registered once.
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
+
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented || !busy) {
return
}
const active = document.activeElement as HTMLElement | null
+
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
return
}
@@ -2264,7 +2272,9 @@ export function ChatBar({
diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts
index 6e580266212..ac04bfacbc6 100644
--- a/apps/desktop/src/app/chat/composer/inline-refs.ts
+++ b/apps/desktop/src/app/chat/composer/inline-refs.ts
@@ -3,12 +3,7 @@ import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
-import {
- composerPlainText,
- normalizeComposerEditorDom,
- placeCaretEnd,
- refChipElement
-} from './rich-editor'
+import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
@@ -159,6 +154,7 @@ export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonl
editor.focus({ preventScroll: true })
const selection = window.getSelection()
+
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)
diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx
index abc941bf10d..afaca08c9c8 100644
--- a/apps/desktop/src/app/chat/composer/model-pill.tsx
+++ b/apps/desktop/src/app/chat/composer/model-pill.tsx
@@ -94,13 +94,7 @@ export function ModelPill({
-
diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
index 573f5eccd70..02f41e2605c 100644
--- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
+++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
@@ -4,14 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList
-} from '@/components/ui/command'
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import {
Dialog,
DialogContent,
@@ -240,7 +233,8 @@ export const CodingStatusRow = memo(function CodingStatusRow({
branchTargets.push({ base: undefined, label: s.newBranch })
}
- const switchTarget = onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
+ const switchTarget =
+ onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
// Other worktrees to jump into — everything except the one we're already in
// (matched by its checked-out branch) and the bare/main placeholder entry.
diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
index f8c3cc520b3..5e559365112 100644
--- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
+++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
@@ -76,7 +76,12 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
return (
+
}
// Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the
// in-app preview pane instead. (isOpen still toggles the pane closed.)
diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
index 3aefbfee0a5..79da0032c01 100644
--- a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
+++ b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx
@@ -11,7 +11,14 @@ function renderPopover(kind: '@' | '/', loading = false) {
const rendered = render(
-
+
)
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index 26f41864baf..ecc13808413 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -226,9 +226,10 @@ const attachToMain = (attachment: ComposerAttachment) => {
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
const { t } = useI18n()
const copy = t.desktop
+
const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
- }, [copy.imagePreviewFailed])
+ }, [])
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
const trimmed = text.trim()
@@ -329,35 +330,38 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
[currentCwd]
)
- const attachImagePath = useCallback(async (filePath: string) => {
- if (!filePath) {
- return false
- }
-
- const baseAttachment: ComposerAttachment = {
- id: attachmentId('image', filePath),
- kind: 'image',
- label: pathLabel(filePath),
- detail: filePath,
- path: filePath
- }
-
- attachToMain(baseAttachment)
-
- try {
- const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
-
- if (previewUrl) {
- addComposerAttachment({ ...baseAttachment, previewUrl })
+ const attachImagePath = useCallback(
+ async (filePath: string) => {
+ if (!filePath) {
+ return false
}
- return true
- } catch (err) {
- notifyError(err, copy.imagePreviewFailed)
+ const baseAttachment: ComposerAttachment = {
+ id: attachmentId('image', filePath),
+ kind: 'image',
+ label: pathLabel(filePath),
+ detail: filePath,
+ path: filePath
+ }
- return true
- }
- }, [])
+ attachToMain(baseAttachment)
+
+ try {
+ const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
+
+ if (previewUrl) {
+ addComposerAttachment({ ...baseAttachment, previewUrl })
+ }
+
+ return true
+ } catch (err) {
+ notifyError(err, copy.imagePreviewFailed)
+
+ return true
+ }
+ },
+ [copy.imagePreviewFailed]
+ )
const attachImageBlob = useCallback(
async (blob: Blob) => {
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 51697829d17..b61df2337b7 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -88,10 +88,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> {
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise
onReload: (parentId: string | null) => Promise
- onRestoreToMessage?: (
- messageId: string,
- target?: { text?: string; userOrdinal?: number | null }
- ) => Promise
+ onRestoreToMessage?: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise
onDismissError?: (messageId: string) => void
@@ -320,7 +317,12 @@ export function ChatView({
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
- !isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
+ !isSecondaryWindow() &&
+ freshDraftReady &&
+ !isRoutedSessionView &&
+ !selectedSessionId &&
+ !activeSessionId &&
+ messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx
index e58f37490ec..408e9d86b88 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx
@@ -503,9 +503,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
return (
- {beforeRows > 0 && (
-
- )}
+ {beforeRows > 0 &&
}
{visibleChunks.map(chunk => (
@@ -547,9 +545,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
))}
- {afterRows > 0 && (
-
- )}
+ {afterRows > 0 &&
}
)
@@ -880,11 +876,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return (
setForcePreview(true) }}
title={binary ? t.preview.binaryTitle : t.preview.largeTitle}
tone="warning"
@@ -981,10 +973,5 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
)
}
- return (
-
- )
+ return
}
diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx
index ba17b1322de..51e5539bac9 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx
@@ -7,7 +7,9 @@ import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
- vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+ window.setTimeout(() => callback(Date.now()), 0)
+ )
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})
diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx
index bc34e4b2316..2b77007a730 100644
--- a/apps/desktop/src/app/chat/right-rail/preview.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview.tsx
@@ -75,7 +75,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const tabs = useMemo(
() => [
- ...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] : []),
+ ...(previewTarget
+ ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab]
+ : []),
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
],
[filePreviewTabs, previewTarget, t.preview.tab]
diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx
index 45b20ce13dd..3963aaf3dbd 100644
--- a/apps/desktop/src/app/chat/sidebar/chrome.tsx
+++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx
@@ -146,10 +146,7 @@ export function SidebarRowLeadGlyph({
}) {
return (
{children}
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx
index 06ca1fc96cf..19665341f3d 100644
--- a/apps/desktop/src/app/chat/sidebar/index.tsx
+++ b/apps/desktop/src/app/chat/sidebar/index.tsx
@@ -77,13 +77,7 @@ import {
toggleSidebarMessagingOpen,
unpinSession
} from '@/store/layout'
-import {
- $newChatProfile,
- $profiles,
- $profileScope,
- ALL_PROFILES,
- normalizeProfileKey
-} from '@/store/profile'
+import { $newChatProfile, $profiles, $profileScope, ALL_PROFILES, normalizeProfileKey } from '@/store/profile'
import {
$activeProjectId,
$projects,
@@ -247,7 +241,12 @@ function ReorderableList({
}
return (
-
+
{children}
@@ -1119,9 +1118,7 @@ export function ChatSidebar({
)
const recentsVirtualizes =
- !displayAgentGroups?.length &&
- !agentProjectTree?.length &&
- displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
+ !displayAgentGroups?.length && !agentProjectTree?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
// Keep the persisted parent + worktree orders reconciled with what's on screen:
// freshly-seen repos/worktrees surface at the top, vanished ones drop out of
@@ -1439,11 +1436,13 @@ export function ChatSidebar({
}
label={sessionsLabel}
labelMeta={
- worktreeGroupingActive
- ? reposScanning && !projectsSkeletonVisible
- ?
- : undefined
- : recentsMeta
+ worktreeGroupingActive ? (
+ reposScanning && !projectsSkeletonVisible ? (
+
+ ) : undefined
+ ) : (
+ recentsMeta
+ )
}
liveSessions={inProject ? agentSessions : undefined}
onArchiveSession={onArchiveSession}
@@ -1458,7 +1457,9 @@ export function ChatSidebar({
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
- projectBackRow={inProject ? : undefined}
+ projectBackRow={
+ inProject ? : undefined
+ }
projectContent={inProject ? enteredProjectContent : undefined}
projectOverview={projectOverview}
projectOverviewPreviews={overviewPreviews}
@@ -1562,7 +1563,15 @@ interface SidebarSectionHeaderProps {
collapsible?: boolean
}
-function SidebarSectionHeader({ label, open, onToggle, action, meta, icon, collapsible = true }: SidebarSectionHeaderProps) {
+function SidebarSectionHeader({
+ label,
+ open,
+ onToggle,
+ action,
+ meta,
+ icon,
+ collapsible = true
+}: SidebarSectionHeaderProps) {
const labelBody = (
<>
{icon}
@@ -1597,7 +1606,10 @@ function SidebarSessionSkeletons() {
return (
{['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => (
-
+
@@ -1732,8 +1744,7 @@ function SidebarSessionsSection({
const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0)
const showEmptyState =
- forceEmptyState ||
- (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0)
+ forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0)
// The flat recents/pinned list is the only place sessions reorder by hand;
// grouped/tree views always sort by creation date and never drag.
@@ -1828,7 +1839,11 @@ function SidebarSessionsSection({
inner =
projectsDraggable && onReorderProjects ? (
-
project.id)} onReorder={onReorderProjects} sensors={dndSensors}>
+ project.id)}
+ onReorder={onReorderProjects}
+ sensors={dndSensors}
+ >
{rows}
) : (
@@ -1837,7 +1852,12 @@ function SidebarSessionsSection({
} else if (groups?.length) {
// Profile/source groups never reorder; render them flat with static rows.
inner = groups.map(group => (
-
+
))
} else if (flatVirtualized) {
const virtual = (
diff --git a/apps/desktop/src/app/chat/sidebar/load-more-row.tsx b/apps/desktop/src/app/chat/sidebar/load-more-row.tsx
index 4a5ec016ffb..e0085fdb587 100644
--- a/apps/desktop/src/app/chat/sidebar/load-more-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/load-more-row.tsx
@@ -23,7 +23,11 @@ export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLo
onClick={onClick}
type="button"
>
- {loading ? : }
+ {loading ? (
+
+ ) : (
+
+ )}
)
}
diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
index d8f7a6bc161..612305b1479 100644
--- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
+++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
@@ -132,7 +132,11 @@ export function ProfileRail() {
const defaultProfile = profiles.find(profile => profile.is_default)
const onDefault = !isAll && activeKey === 'default'
- const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
+ const named = sortByProfileOrder(
+ profiles.filter(profile => !profile.is_default),
+ order
+ )
+
const multiProfile = profiles.length > 1
// distance constraint: a small drag reorders, a tap still selects the profile.
@@ -482,7 +486,11 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
{p.rename}
-
+
{t.common.delete}
diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx
index 18cffc824e1..16cbb04983d 100644
--- a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx
+++ b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx
@@ -3,7 +3,14 @@ import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
diff --git a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx
index 44dd4bb3187..561074d623c 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx
@@ -4,7 +4,14 @@ import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
@@ -122,7 +129,8 @@ function RepoFlatSection({
// A live `git worktree list` hit wins over an old dismissal: if git says the
// worktree exists again (or still exists after "hide from sidebar"), surface it.
const ordered = overlaidGroups.filter(
- group => group.isMain || !dismissedWorktrees.includes(group.id) || (group.path && discoveredWorktreePaths.has(group.path))
+ group =>
+ group.isMain || !dismissedWorktrees.includes(group.id) || (group.path && discoveredWorktreePaths.has(group.path))
)
const repoCount = ordered.reduce((sum, group) => sum + group.sessions.length, 0)
@@ -248,7 +256,9 @@ function RepoFlatSection({
onNewSession(repo.path)} />
+ onNewSession && (
+ onNewSession(repo.path)} />
+ )
}
count={repoCount}
emphasis
diff --git a/apps/desktop/src/app/chat/sidebar/projects/model.ts b/apps/desktop/src/app/chat/sidebar/projects/model.ts
index 7172e08a7a8..e5931f0bcb8 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/model.ts
+++ b/apps/desktop/src/app/chat/sidebar/projects/model.ts
@@ -19,7 +19,11 @@ export const PROJECT_PREVIEW_COUNT = 3
const WORKTREE_PROBE_CONCURRENCY = 4
const pathListKey = (paths: string[]): string =>
- paths.map(path => path.trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)).join('\n')
+ paths
+ .map(path => path.trim())
+ .filter(Boolean)
+ .sort((a, b) => a.localeCompare(b))
+ .join('\n')
// Every session in a project, across its repos/worktrees (order-agnostic).
const projectSessions = (project: SidebarProjectTree): SessionInfo[] =>
@@ -63,7 +67,10 @@ export function sortProjectsForOverview(
return aHasSessions ? -1 : 1
}
- return projectActivityTime(b) - projectActivityTime(a) || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
+ return (
+ projectActivityTime(b) - projectActivityTime(a) ||
+ a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
+ )
})
}
diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
index 8bfe82384dd..b3f779f2f2e 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
@@ -116,7 +116,9 @@ export function ProjectOverviewRow({
- {onNewSession && onNewSession(project.path)} />}
+ {onNewSession && (
+ onNewSession(project.path)} />
+ )}
>
}
diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
index 8bdb5df174b..8995013cb75 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
@@ -31,10 +31,34 @@ import type { SidebarProjectTree } from './workspace-groups'
// Curated codicons for the project glyph (tinted by the chosen color).
const ICONS = [
- 'folder-library', 'repo', 'rocket', 'beaker', 'flame', 'star-full', 'heart',
- 'zap', 'target', 'lightbulb', 'tools', 'device-desktop', 'device-mobile', 'terminal',
- 'dashboard', 'globe', 'broadcast', 'cloud', 'database', 'package', 'book',
- 'organization', 'bug', 'shield', 'key', 'gift', 'telescope', 'home'
+ 'folder-library',
+ 'repo',
+ 'rocket',
+ 'beaker',
+ 'flame',
+ 'star-full',
+ 'heart',
+ 'zap',
+ 'target',
+ 'lightbulb',
+ 'tools',
+ 'device-desktop',
+ 'device-mobile',
+ 'terminal',
+ 'dashboard',
+ 'globe',
+ 'broadcast',
+ 'cloud',
+ 'database',
+ 'package',
+ 'book',
+ 'organization',
+ 'bug',
+ 'shield',
+ 'key',
+ 'gift',
+ 'telescope',
+ 'home'
]
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
@@ -114,7 +138,12 @@ export function ProjectMenu({
{/* Closing the menu refocuses the trigger (also the popover anchor),
which the appearance popover would read as focus-outside and die on.
Suppress that refocus so it survives. */}
- event.preventDefault()} sideOffset={6}>
+ event.preventDefault()}
+ sideOffset={6}
+ >
{!project.isAuto && (
<>
openProjectRename(target)}>
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
index e874b4ccc4d..911aaaeecb2 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
@@ -129,7 +129,11 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
)}
{hiddenCount > 0 &&
(isProfileGroup ? (
-
+
) : (
{
})
it('falls back to label order for equally-idle lanes', () => {
- const groups = [
- lane({ id: 'b', label: 'beta', isMain: false }),
- lane({ id: 'a', label: 'alpha', isMain: false })
- ]
+ const groups = [lane({ id: 'b', label: 'beta', isMain: false }), lane({ id: 'a', label: 'alpha', isMain: false })]
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['alpha', 'beta'])
})
@@ -108,7 +105,11 @@ describe('sortWorktreeGroups', () => {
describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
it('injects a linked worktree lane discovered by git that has no sessions yet', () => {
- const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
+ const repo = {
+ id: '/repo',
+ path: '/repo',
+ groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
+ }
const discovered: HermesGitWorktree[] = [
{ branch: 'feature', detached: false, isMain: false, locked: false, path: '/repo-wt-feature' }
@@ -122,7 +123,11 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
})
it('never spawns a lane per kanban task worktree', () => {
- const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
+ const repo = {
+ id: '/repo',
+ path: '/repo',
+ groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
+ }
const discovered: HermesGitWorktree[] = [
{ branch: 'wt/t_aaaaaaaa', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_aaaaaaaa' },
@@ -137,7 +142,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
id: '/repo',
path: '/repo',
groups: [
- lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
+ lane({
+ id: '/repo::branch::main',
+ label: 'main',
+ isMain: true,
+ path: '/repo',
+ sessions: [makeSession('/repo')]
+ })
]
}
@@ -155,22 +166,34 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
it('is a no-op when git worktree list is unavailable (remote backend)', () => {
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
- expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, undefined).map(g => g.label)).toEqual(['main'])
+ expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, undefined).map(g => g.label)).toEqual([
+ 'main'
+ ])
})
it('does not add a second "main" for a linked worktree checked out on main', () => {
- const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
+ const groups = [
+ lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
+ ]
const discovered: HermesGitWorktree[] = [
{ branch: 'main', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/main-mirror' }
]
- expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, discovered).filter(g => g.label === 'main')).toHaveLength(1)
+ expect(
+ mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, discovered).filter(g => g.label === 'main')
+ ).toHaveLength(1)
})
it('surfaces a user-named "New worktree" under .worktrees/ as its own lane', () => {
const discovered: HermesGitWorktree[] = [
- { branch: 'hermes/test-gui-stuff', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/test-gui-stuff' }
+ {
+ branch: 'hermes/test-gui-stuff',
+ detached: false,
+ isMain: false,
+ locked: false,
+ path: '/repo/.worktrees/test-gui-stuff'
+ }
]
const merged = mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups: [] }, discovered)
@@ -185,7 +208,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
id: '/repo',
path: '/repo',
groups: [
- lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] }),
+ lane({
+ id: '/repo::branch::main',
+ label: 'main',
+ isMain: true,
+ path: '/repo',
+ sessions: [makeSession('/repo')]
+ }),
lane({
id: '/repo-ci',
label: 'hermes-agent-ci',
@@ -297,7 +326,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
id: '/repo',
path: '/repo',
groups: [
- lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
+ lane({
+ id: '/repo::branch::main',
+ label: 'main',
+ isMain: true,
+ path: '/repo',
+ sessions: [makeSession('/repo')]
+ })
]
}
@@ -322,10 +357,20 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
const repo = {
id: '/repo',
path: '/repo',
- groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
+ groups: [
+ lane({
+ id: '/repo::branch::main',
+ label: 'main',
+ isMain: true,
+ path: '/repo',
+ sessions: [makeSession('/repo')]
+ })
+ ]
}
- const discovered: HermesGitWorktree[] = [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }]
+ const discovered: HermesGitWorktree[] = [
+ { branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }
+ ]
const home = mergeRepoWorktreeGroups(repo, discovered).find(g => g.isHome)
@@ -338,12 +383,26 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
id: '/repo',
path: '/repo',
groups: [
- lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'a' })] }),
- lane({ id: '/repo::branch::old', label: 'old-feature', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'b' })] })
+ lane({
+ id: '/repo::branch::main',
+ label: 'main',
+ isMain: true,
+ path: '/repo',
+ sessions: [makeSession('/repo', { id: 'a' })]
+ }),
+ lane({
+ id: '/repo::branch::old',
+ label: 'old-feature',
+ isMain: true,
+ path: '/repo',
+ sessions: [makeSession('/repo', { id: 'b' })]
+ })
]
}
- const discovered: HermesGitWorktree[] = [{ branch: 'bb/live', detached: false, isMain: true, locked: false, path: '/repo' }]
+ const discovered: HermesGitWorktree[] = [
+ { branch: 'bb/live', detached: false, isMain: true, locked: false, path: '/repo' }
+ ]
const merged = mergeRepoWorktreeGroups(repo, discovered)
const home = merged.find(g => g.isHome)
@@ -354,7 +413,19 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
})
it('leaves main lanes untouched on a remote backend (no git probe)', () => {
- const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })] }
+ const repo = {
+ id: '/repo',
+ path: '/repo',
+ groups: [
+ lane({
+ id: '/repo::branch::main',
+ label: 'main',
+ isMain: true,
+ path: '/repo',
+ sessions: [makeSession('/repo')]
+ })
+ ]
+ }
// No discovered worktrees → no live branch truth → backend label stands.
const merged = mergeRepoWorktreeGroups(repo, undefined)
@@ -411,9 +482,9 @@ describe('liveSessionProjectId', () => {
// "Convert a branch" / "new worktree" land at `/.worktrees/`,
// so they belong to the same auto project as the repo root and must show in
// the overview at once, not wait for the next backend refresh.
- expect(
- liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])
- ).toBe('/www/app')
+ expect(liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe(
+ '/www/app'
+ )
})
it('routes an in-tree worktree session to the owning explicit project', () => {
@@ -488,7 +559,9 @@ describe('overlayLiveLanes', () => {
label: 'app',
path: '/www/app',
sessionCount: 1,
- groups: [lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })]
+ groups: [
+ lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })
+ ]
}
]
})
@@ -513,7 +586,12 @@ describe('overlayLiveLanes', () => {
path: '/www/app',
sessionCount: 1,
groups: [
- lane({ id: '/www/app::branch::baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [existing] })
+ lane({
+ id: '/www/app::branch::baby',
+ label: 'baby',
+ path: '/www/app/.worktrees/baby',
+ sessions: [existing]
+ })
]
}
]
@@ -560,7 +638,10 @@ describe('overlayLiveLanes', () => {
})
it('places into a visual-only discovered worktree lane after merge', () => {
- const discovered = [{ path: '/www/app-retry', branch: 'bb/ci-install-retry', isMain: false, detached: false, locked: false }]
+ const discovered = [
+ { path: '/www/app-retry', branch: 'bb/ci-install-retry', isMain: false, detached: false, locked: false }
+ ]
+
const groups = mergeRepoWorktreeGroups({ id: '/www/app', path: '/www/app', groups: [] }, discovered)
const project = projectNode({
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
index 62e31973c33..4ab4261af61 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
+++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
@@ -280,7 +280,11 @@ export function mergeRepoWorktreeGroups(
continue
}
- const label = (worktree.isMain ? worktree.branch?.trim() || DEFAULT_BRANCH_LABEL : worktree.branch?.trim()) || baseName(wtPath) || wtPath
+ const label =
+ (worktree.isMain ? worktree.branch?.trim() || DEFAULT_BRANCH_LABEL : worktree.branch?.trim()) ||
+ baseName(wtPath) ||
+ wtPath
+
const id = worktree.isMain ? branchLaneId(repo.id, label) : wtPath
const alreadySeen =
@@ -479,7 +483,9 @@ export function overlayRepoLanes(
lane =
lanes.find(g => g.id === placed.id) ??
- (placed.isMain ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase()) : undefined) ??
+ (placed.isMain
+ ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase())
+ : undefined) ??
(!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined)
if (!lane) {
diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
index 390e081af5d..1a32f68b2f5 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
@@ -4,7 +4,14 @@ import { useCallback, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenu,
@@ -70,7 +77,15 @@ export function WorkspaceAddButton({ label, onClick }: { label: string; onClick:
}
// Reveals the next page of already-loaded rows within a workspace/worktree.
-export function WorkspaceShowMoreButton({ count, label, onClick }: { count: number; label: string; onClick: () => void }) {
+export function WorkspaceShowMoreButton({
+ count,
+ label,
+ onClick
+}: {
+ count: number
+ label: string
+ onClick: () => void
+}) {
const { t } = useI18n()
const text = t.sidebar.showMoreIn(count, label)
diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
index 865131e06d1..08c13550a43 100644
--- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
@@ -93,7 +93,16 @@ interface ItemSpec {
variant?: 'destructive'
}
-function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onBranch, onArchive, onDelete }: SessionActions) {
+function useSessionActions({
+ sessionId,
+ title,
+ pinned = false,
+ profile,
+ onPin,
+ onBranch,
+ onArchive,
+ onDelete
+}: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false)
diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx
index 7b636ed4a5a..2451f4d414e 100644
--- a/apps/desktop/src/app/chat/sidebar/session-row.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx
@@ -81,7 +81,7 @@ export function SidebarSessionRow({
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
- const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
+ const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@@ -159,7 +159,9 @@ export function SidebarSessionRow({
{...rest}
>
{isWorking && !needsInput && }
- {
+ {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx
index 3113e021a16..681952c355b 100644
--- a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx
+++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx
@@ -116,7 +116,10 @@ export const VirtualSessionList: FC = ({
// DndContext + SortableContext (keyed on the same ids); the virtualized rows
// just consume that context via useSortable.
return (
-
+
{rows}
diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx
index a1a46727c88..9eacc0f41ee 100644
--- a/apps/desktop/src/app/command-center/index.tsx
+++ b/apps/desktop/src/app/command-center/index.tsx
@@ -5,14 +5,7 @@ import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { SearchField } from '@/components/ui/search-field'
import { SegmentedControl } from '@/components/ui/segmented-control'
-import {
- getActionStatus,
- getLogs,
- getStatus,
- getUsageAnalytics,
- restartGateway,
- updateHermes
-} from '@/hermes'
+import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
@@ -336,11 +329,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
onClick={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
title={pinned ? cc.unpinSession : cc.pinSession}
>
- {pinned ? (
-
- ) : (
-
- )}
+ {pinned ?
:
}
void exportSession(session.id, { session, title: sessionTitle(session) })}
@@ -404,7 +393,11 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
{systemAction && (
{systemAction.name} ·{' '}
- {systemAction.running ? cc.actionRunning : systemAction.exit_code === 0 ? cc.actionDone : cc.actionFailed}
+ {systemAction.running
+ ? cc.actionRunning
+ : systemAction.exit_code === 0
+ ? cc.actionDone
+ : cc.actionFailed}
)}
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx
index 63b48ce8e98..bfda963204c 100644
--- a/apps/desktop/src/app/command-palette/index.tsx
+++ b/apps/desktop/src/app/command-palette/index.tsx
@@ -44,7 +44,12 @@ import {
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $repoWorktrees } from '@/store/coding-status'
-import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
+import {
+ $commandPaletteOpen,
+ $commandPalettePage,
+ closeCommandPalette,
+ setCommandPaletteOpen
+} from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { openPetGenerate } from '@/store/pet-generate'
import { requestStartWorkSession } from '@/store/projects'
@@ -206,7 +211,8 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
return true
}
- const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
+ const background =
+ target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
}
@@ -703,7 +709,13 @@ export function CommandPalette() {
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
- { closeCommandPalette(); openPetGenerate() }} search={search} />
+ {
+ closeCommandPalette()
+ openPetGenerate()
+ }}
+ search={search}
+ />
) : page === 'install-theme' ? (
) : (
diff --git a/apps/desktop/src/app/command-palette/pet-palette-page.tsx b/apps/desktop/src/app/command-palette/pet-palette-page.tsx
index 9e75b666ef6..4e1afd1c2e9 100644
--- a/apps/desktop/src/app/command-palette/pet-palette-page.tsx
+++ b/apps/desktop/src/app/command-palette/pet-palette-page.tsx
@@ -183,7 +183,9 @@ export function PetInlineToggle() {
aria-pressed={enabled}
className={cn(
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
- enabled ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
+ enabled
+ ? 'bg-(--chrome-action-hover) text-foreground'
+ : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
)}
disabled={Boolean(busy)}
onClick={toggle}
diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx
index 459c3fd558f..342f53ee042 100644
--- a/apps/desktop/src/app/cron/index.tsx
+++ b/apps/desktop/src/app/cron/index.tsx
@@ -285,7 +285,9 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
// it, queue a scroll, then clear the one-shot focus so re-opening cron
// normally doesn't re-trigger it.
useEffect(() => {
- if (!focusJobId) {return}
+ if (!focusJobId) {
+ return
+ }
const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId)
@@ -313,7 +315,9 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
useEffect(() => {
const target = pendingScrollRef.current
- if (!target || selectedJob?.id !== target) {return}
+ if (!target || selectedJob?.id !== target) {
+ return
+ }
pendingScrollRef.current = null
requestAnimationFrame(() => {
@@ -460,30 +464,30 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
-
+
)
}
@@ -646,20 +650,28 @@ function CronJobRuns({
const load = () =>
getCronJobRuns(jobId)
.then(result => {
- if (!cancelled) {setRuns(result)}
+ if (!cancelled) {
+ setRuns(result)
+ }
})
.catch(() => {
- if (!cancelled) {setRuns(prev => prev ?? [])}
+ if (!cancelled) {
+ setRuns(prev => prev ?? [])
+ }
})
void load()
const intervalId = window.setInterval(() => {
- if (document.visibilityState === 'visible') {void load()}
+ if (document.visibilityState === 'visible') {
+ void load()
+ }
}, RUNS_POLL_INTERVAL_MS)
const onVisible = () => {
- if (document.visibilityState === 'visible') {void load()}
+ if (document.visibilityState === 'visible') {
+ void load()
+ }
}
document.addEventListener('visibilitychange', onVisible)
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index c1e731f8d68..9df44d628ce 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -43,11 +43,15 @@ import {
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
-import { $paneOpen } from '../store/panes'
import { respondToApprovalAction } from '../store/native-notifications'
+import { $paneOpen } from '../store/panes'
import { setPetActivity } from '../store/pet'
import { setPetScale } from '../store/pet-gallery'
-import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
+import {
+ setPetOverlayOpenAppHandler,
+ setPetOverlayScaleHandler,
+ setPetOverlaySubmitHandler
+} from '../store/pet-overlay'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
@@ -1225,6 +1229,7 @@ export function DesktopController() {
(chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) ||
(chatOpen && !narrowViewport && fileBrowserOpen) ||
(chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen)
+
// Once the terminal would share its rail with another sidebar, drop it to a
// full-width row beneath them rather than cramming in one more skinny column.
const terminalAsRow = terminalSidebarOpen && railColumnOpen
diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
index 2db75c8bfe8..eb893c3675a 100644
--- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
+++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
@@ -68,7 +68,9 @@ class FakeWebSocket {
}
private emit(type: string, ev: unknown) {
- for (const fn of this.listeners[type] ?? []) fn(ev)
+ for (const fn of this.listeners[type] ?? []) {
+ fn(ev)
+ }
}
}
@@ -250,9 +252,11 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()
+
for (let i = 0; i < 8; i += 1) {
await advanceBackoff()
}
+
expect($desktopBoot.get().error).toBeTruthy()
// The remote comes back: next reconnect attempt opens.
diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
index a4d134f3836..1db1c2aaa0d 100644
--- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
+++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
@@ -377,10 +377,12 @@ export function useGatewayBoot({
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
+
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
+
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx
index f7f3eaa91e2..659c655dccf 100644
--- a/apps/desktop/src/app/messaging/index.tsx
+++ b/apps/desktop/src/app/messaging/index.tsx
@@ -108,24 +108,27 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms])
const [selectedId, setSelectedId] = useRouteEnumParam('platform', platformIds, platformIds[0] ?? '')
- const refreshPlatforms = useCallback(async (silent = false) => {
- if (!silent) {
- setRefreshing(true)
- }
+ const refreshPlatforms = useCallback(
+ async (silent = false) => {
+ if (!silent) {
+ setRefreshing(true)
+ }
- try {
- const result = await getMessagingPlatforms()
- setPlatforms(result.platforms)
- } catch (err) {
- if (!silent) {
- notifyError(err, m.loadFailed)
+ try {
+ const result = await getMessagingPlatforms()
+ setPlatforms(result.platforms)
+ } catch (err) {
+ if (!silent) {
+ notifyError(err, m.loadFailed)
+ }
+ } finally {
+ if (!silent) {
+ setRefreshing(false)
+ }
}
- } finally {
- if (!silent) {
- setRefreshing(false)
- }
- }
- }, [m])
+ },
+ [m]
+ )
useRefreshHotkey(() => void refreshPlatforms())
@@ -532,7 +535,7 @@ const PLATFORM_INTRO: Record = {
wecom_callback:
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
weixin:
- 'Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent\'s iLink Bot API and saves the credentials.',
+ "Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent's iLink Bot API and saves the credentials.",
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
api_server:
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
diff --git a/apps/desktop/src/app/pet-generate/components/generate-unavailable.tsx b/apps/desktop/src/app/pet-generate/components/generate-unavailable.tsx
index d3161d2a771..465dbc66714 100644
--- a/apps/desktop/src/app/pet-generate/components/generate-unavailable.tsx
+++ b/apps/desktop/src/app/pet-generate/components/generate-unavailable.tsx
@@ -16,7 +16,9 @@ export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
-
Add an image backend to generate
+
+ Add an image backend to generate
+
Hatching a custom pet needs a provider that can ground on a reference image.
diff --git a/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx b/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
index 8adb6c3f9f2..46ef28343f3 100644
--- a/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
+++ b/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
@@ -16,7 +16,17 @@ import { frameCountForRow } from '../lib/frame-count'
const PREVIEW_SCALE = 0.7
const PREVIEW_STATE_MS = 1400
-const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed', 'waiting']
+const PREVIEW_ROWS = [
+ 'idle',
+ 'waving',
+ 'running-right',
+ 'running-left',
+ 'running',
+ 'review',
+ 'jumping',
+ 'failed',
+ 'waiting'
+]
interface HatchPreviewProps {
pet: PetInfo
@@ -38,7 +48,11 @@ export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: Hatch
// hands off to the normal state-cycling preview.
const [celebrating, setCelebrating] = useState(false)
const [stateIndex, setStateIndex] = useState(0)
- const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
+
+ const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(
+ row => frameCountForRow(pet, row) > 0
+ )
+
const rows = previewRows.length > 0 ? previewRows : ['idle']
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
const canJump = frameCountForRow(pet, 'jumping') > 0
@@ -58,10 +72,13 @@ export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: Hatch
setCelebrating(true)
- const id = setTimeout(() => {
- setCelebrating(false)
- setStateIndex(0)
- }, 2 * (pet.loopMs ?? 1100))
+ const id = setTimeout(
+ () => {
+ setCelebrating(false)
+ setStateIndex(0)
+ },
+ 2 * (pet.loopMs ?? 1100)
+ )
return () => clearTimeout(id)
}, [revealed, pet.loopMs])
diff --git a/apps/desktop/src/app/pet-generate/lib/frame-count.ts b/apps/desktop/src/app/pet-generate/lib/frame-count.ts
index 97a49a8cd6b..d65dc5e70e6 100644
--- a/apps/desktop/src/app/pet-generate/lib/frame-count.ts
+++ b/apps/desktop/src/app/pet-generate/lib/frame-count.ts
@@ -22,5 +22,11 @@ const ROW_TO_FRAME_KEY: Record
= {
export function frameCountForRow(pet: PetInfo, row: string): number {
const mapped = ROW_TO_FRAME_KEY[row]
- return pet.framesByRow?.[row] ?? pet.framesByState?.[row] ?? (mapped ? pet.framesByState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
+ return (
+ pet.framesByRow?.[row] ??
+ pet.framesByState?.[row] ??
+ (mapped ? pet.framesByState?.[mapped] : undefined) ??
+ pet.framesPerState ??
+ 0
+ )
}
diff --git a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
index 33bd3350f02..60b1ec3d00b 100644
--- a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
+++ b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
@@ -66,7 +66,14 @@ export function PetGenerateOverlay() {
const working = status === 'generating' || status === 'hatching'
const errored = status === 'error' && drafts.length === 0
const stepOne = status === 'idle' || status === 'ready'
- const banner = errored ? error || copy.genericError : working ? copy.backgroundHint : stepOne ? copy.slowProviderHint : undefined
+
+ const banner = errored
+ ? error || copy.genericError
+ : working
+ ? copy.backgroundHint
+ : stepOne
+ ? copy.slowProviderHint
+ : undefined
return (
-
- {u.manualPickedUp}
-
+ {u.manualPickedUp}
{u.done}
@@ -396,14 +377,10 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
{label}
-
- {body}
-
+ {body}
{currentMessage ? (
-
- {currentMessage}
-
+ {currentMessage}
) : null}
@@ -444,9 +421,7 @@ function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss
{message || u.errorBody}
}
- title={
- {u.errorTitle}
- }
+ title={{u.errorTitle}}
>
{u.tryAgain}
diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
index 655ff389ec2..9c0e918cf74 100644
--- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
+++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx
@@ -2,7 +2,7 @@
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
-import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState, type ComponentProps } from 'react'
+import { type ComponentProps, type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
@@ -41,11 +41,7 @@ const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-md px-2.5 py-1.5
const CLARIFY_SHELL_CLASS =
'relative mb-3 mt-2 rounded-[0.5rem] border border-border/70 bg-card/40 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
-function ClarifyShell({
- children,
- className,
- ...props
-}: ComponentProps<'div'>) {
+function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) {
return (
@@ -151,7 +147,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
setSubmitting(false)
}
},
- [gateway, matchingRequest, ready]
+ [copy.gatewayDisconnected, copy.notReady, copy.sendFailed, gateway, matchingRequest, ready]
)
const handleTextareaKey = useCallback(
diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx
index 097b106281e..fe3c8e7f165 100644
--- a/apps/desktop/src/components/assistant-ui/directive-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx
@@ -159,7 +159,12 @@ export const DIRECTIVE_CHIP_CLASS =
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
const HERMES_DIRECTIVE_RE = new RegExp(
- '@(file|folder|url|image|tool|line|terminal|session):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
+ '@(file|folder|url|image|tool|line|terminal|session):(' +
+ '`[^`\\n]+`' +
+ '|"[^"\\n]+"' +
+ "|'[^'\\n]+'" +
+ '|\\S+' +
+ ')',
'g'
)
@@ -398,9 +403,7 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
// Remote gateway: the image lives on the gateway's disk, not ours — fetch
// it over the authenticated API. Local: read it straight off this disk.
const load =
- window.hermesDesktop && isRemoteGateway()
- ? gatewayMediaDataUrl(id)
- : window.hermesDesktop?.readFileDataUrl(id)
+ window.hermesDesktop && isRemoteGateway() ? gatewayMediaDataUrl(id) : window.hermesDesktop?.readFileDataUrl(id)
void Promise.resolve(load)
.then(url => alive && url && setSrc(url))
diff --git a/apps/desktop/src/components/assistant-ui/thread-list.tsx b/apps/desktop/src/components/assistant-ui/thread-list.tsx
index 8c98b88a590..1bdc96d6c16 100644
--- a/apps/desktop/src/components/assistant-ui/thread-list.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread-list.tsx
@@ -1,7 +1,7 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import {
- type CSSProperties,
type ComponentProps,
+ type CSSProperties,
type FC,
memo,
type ReactNode,
@@ -145,6 +145,7 @@ const ThreadMessageListInner: FC = ({
// sticky user bubble falls back to its ~4px default and slides under the OS
// traffic lights.
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
+
const threadContentTopPad = secondaryWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
diff --git a/apps/desktop/src/components/assistant-ui/thread-timeline.tsx b/apps/desktop/src/components/assistant-ui/thread-timeline.tsx
index f52c27d1adb..ccc4002771a 100644
--- a/apps/desktop/src/components/assistant-ui/thread-timeline.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread-timeline.tsx
@@ -272,9 +272,7 @@ const TimelinePopover: FC<{
type="button"
{...hoverProps(index, onHover)}
>
-
- {entry.preview}
-
+ {entry.preview}
))}
@@ -300,9 +298,7 @@ const TimelineTicks: FC<{
diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx
index 8b3fb2de373..c087c5986b8 100644
--- a/apps/desktop/src/components/assistant-ui/thread.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread.tsx
@@ -194,9 +194,9 @@ export const Thread: FC<{
const { t } = useI18n()
const copy = t.assistant.thread
- const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<(RestoreMessageTarget & { messageId: string }) | null>(
- null
- )
+ const [restoreConfirmTarget, setRestoreConfirmTarget] = useState<
+ (RestoreMessageTarget & { messageId: string }) | null
+ >(null)
const closeRestoreConfirm = useCallback(() => setRestoreConfirmTarget(null), [])
@@ -219,7 +219,9 @@ export const Thread: FC<{
const messageComponents = useMemo(
() => ({
- AssistantMessage: () => ,
+ AssistantMessage: () => (
+
+ ),
SystemMessage,
UserEditComposer: () => ,
UserMessage: () => (
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts
index 2322d22d53a..142b912e4da 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts
@@ -182,7 +182,10 @@ describe('buildToolView title actions', () => {
const view = buildToolView(
part({
args: { limit: 5, offset: 1, path: './package.json' },
- result: { content: '1|{\n2| "name": "bb-rainbows",\n3| "private": true,\n4| "version": "0.0.1",\n5| "type": "module",\n6| "description": "extra"' },
+ result: {
+ content:
+ '1|{\n2| "name": "bb-rainbows",\n3| "private": true,\n4| "version": "0.0.1",\n5| "type": "module",\n6| "description": "extra"'
+ },
toolName: 'read_file'
}),
''
@@ -247,7 +250,10 @@ describe('buildToolView title actions', () => {
] as const
for (const [command, expectedTitle] of rows) {
- const view = buildToolView(part({ args: { command }, result: { output: 'ok', exit_code: 0 }, toolName: 'terminal' }), '')
+ const view = buildToolView(
+ part({ args: { command }, result: { output: 'ok', exit_code: 0 }, toolName: 'terminal' }),
+ ''
+ )
expect(view.title).toBe(expectedTitle)
}
@@ -320,8 +326,6 @@ describe('buildToolView caps serialized result size', () => {
describe('countDiffLineStats', () => {
it('counts added and removed lines', () => {
- expect(
- countDiffLineStats(`--- a/x\n+++ b/x\n@@\n-old\n+new\n context\n+another`)
- ).toEqual({ added: 2, removed: 1 })
+ expect(countDiffLineStats(`--- a/x\n+++ b/x\n@@\n-old\n+new\n context\n+another`)).toEqual({ added: 2, removed: 1 })
})
})
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
index 9a3dcee0a65..0cc22b62e48 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts
@@ -1568,7 +1568,9 @@ function dynamicTitle(
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
const command =
- firstStringField(args, ['context', 'preview']) || firstStringField(args, ['command', 'code']) || contextValue(args)
+ firstStringField(args, ['context', 'preview']) ||
+ firstStringField(args, ['command', 'code']) ||
+ contextValue(args)
if (command) {
const action =
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
index 599cc2fbbd5..5b895a7397b 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
@@ -524,7 +524,11 @@ function ToolEntry({ part }: ToolEntryProps) {
{view.stderr &&
stdout
}
- {view.rendersAnsi ? : clampForDisplay(view.stdout)}
+ {view.rendersAnsi ? (
+
+ ) : (
+ clampForDisplay(view.stdout)
+ )}
)}
@@ -537,7 +541,11 @@ function ToolEntry({ part }: ToolEntryProps) {
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
)}
>
- {view.rendersAnsi ? : clampForDisplay(view.stderr)}
+ {view.rendersAnsi ? (
+
+ ) : (
+ clampForDisplay(view.stderr)
+ )}
)}
@@ -550,7 +558,10 @@ function ToolEntry({ part }: ToolEntryProps) {
{view.rendersAnsi ?
: clampForDisplay(view.detail)}
) : (
-
+
)}
))}
diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx
index 4b8bd7b9ee1..bb17f79c3cd 100644
--- a/apps/desktop/src/components/boot-failure-overlay.tsx
+++ b/apps/desktop/src/components/boot-failure-overlay.tsx
@@ -220,9 +220,7 @@ export function BootFailureOverlay() {
{copy.openLogs}
-
- {remoteReauth ? copy.remoteSignInHint : copy.repairHint}
-
+ {remoteReauth ? copy.remoteSignInHint : copy.repairHint}
{logs.length > 0 ? (
diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts
index 9faa4eea27e..3aeae7846e4 100644
--- a/apps/desktop/src/components/boot-failure-reauth.ts
+++ b/apps/desktop/src/components/boot-failure-reauth.ts
@@ -62,9 +62,7 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
const isPassword = list.every(p => Boolean(p.supportsPassword))
const providerLabel =
- list.length === 1
- ? list[0].displayName || list[0].name
- : list.map(p => p.displayName || p.name).join(' / ')
+ list.length === 1 ? list[0].displayName || list[0].name : list.map(p => p.displayName || p.name).join(' / ')
return { isPassword, providerLabel }
}
@@ -75,7 +73,8 @@ export function signInLabel(reauth: RemoteReauth | null, copy: SignInCopy = DEFA
return copy.remoteGateway
}
- const provider = reauth?.providerLabel === DEFAULT_SIGN_IN_COPY.identityProvider ? copy.identityProvider : reauth?.providerLabel
+ const provider =
+ reauth?.providerLabel === DEFAULT_SIGN_IN_COPY.identityProvider ? copy.identityProvider : reauth?.providerLabel
return copy.withProvider(provider ?? copy.identityProvider)
}
diff --git a/apps/desktop/src/components/chat/code-editor-theme.ts b/apps/desktop/src/components/chat/code-editor-theme.ts
index 7e8e0c3f58c..5cfbd170bc8 100644
--- a/apps/desktop/src/components/chat/code-editor-theme.ts
+++ b/apps/desktop/src/components/chat/code-editor-theme.ts
@@ -53,7 +53,12 @@ function makeHighlightStyle(p: GithubPalette): HighlightStyle {
{ color: p.comment, fontStyle: 'italic', tag: [t.comment, t.lineComment, t.blockComment, t.docComment] },
{
color: p.entity,
- tag: [t.function(t.variableName), t.function(t.propertyName), t.definition(t.function(t.variableName)), t.labelName]
+ tag: [
+ t.function(t.variableName),
+ t.function(t.propertyName),
+ t.definition(t.function(t.variableName)),
+ t.labelName
+ ]
},
{ color: p.number, tag: [t.number, t.bool, t.atom] },
{ color: p.constant, tag: [t.constant(t.variableName), t.standard(t.variableName)] },
diff --git a/apps/desktop/src/components/chat/code-editor.tsx b/apps/desktop/src/components/chat/code-editor.tsx
index 102102d5b8d..4d81494f4ae 100644
--- a/apps/desktop/src/components/chat/code-editor.tsx
+++ b/apps/desktop/src/components/chat/code-editor.tsx
@@ -24,7 +24,12 @@ interface CodeEditorProps {
function baseName(filePath: string): string {
const cleaned = filePath.replace(/[\\/]+$/, '')
- return cleaned.slice(cleaned.lastIndexOf('/') + 1).split('\\').pop() ?? cleaned
+ return (
+ cleaned
+ .slice(cleaned.lastIndexOf('/') + 1)
+ .split('\\')
+ .pop() ?? cleaned
+ )
}
// Mirror SourceView's geometry/typography 1:1 so toggling preview⇄edit never
diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx
index 5f71a4398df..eef495c7c54 100644
--- a/apps/desktop/src/components/chat/diff-lines.tsx
+++ b/apps/desktop/src/components/chat/diff-lines.tsx
@@ -534,10 +534,7 @@ function DiffOverviewRuler({ lines }: { lines: DiffLine[] }) {
short diff renders thin, line-aligned ticks instead of stretching a few
changes into gross full-height blocks. A long diff hits the 100% cap and
compresses into a true overview. */}
-
+
{runs.map((run, index) => (
- {beforeRows > 0 && (
-
- )}
+ {beforeRows > 0 &&
}
{visibleLineChunks.map(chunk => (
{chunk.lines.map((line, offset) => {
diff --git a/apps/desktop/src/components/chat/expandable-block.tsx b/apps/desktop/src/components/chat/expandable-block.tsx
index 5d64cf3407f..3933f7ccab5 100644
--- a/apps/desktop/src/components/chat/expandable-block.tsx
+++ b/apps/desktop/src/components/chat/expandable-block.tsx
@@ -18,7 +18,9 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
useLayoutEffect(() => {
const el = innerRef.current
- if (!el) {return}
+ if (!el) {
+ return
+ }
const measure = () => setOverflowing(el.scrollHeight > 121)
measure()
@@ -30,10 +32,7 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
return (
-
+
{children}
{overflowing && (
diff --git a/apps/desktop/src/components/chat/generated-image-result.tsx b/apps/desktop/src/components/chat/generated-image-result.tsx
index e4313d20c51..66d3d38072d 100644
--- a/apps/desktop/src/components/chat/generated-image-result.tsx
+++ b/apps/desktop/src/components/chat/generated-image-result.tsx
@@ -19,7 +19,13 @@ const ASPECT_HINTS: Record
= {
}
function hintedRatio(aspectRatio?: string): number {
- return ASPECT_HINTS[String(aspectRatio ?? '').toLowerCase().trim()] ?? ASPECT_HINTS.landscape
+ return (
+ ASPECT_HINTS[
+ String(aspectRatio ?? '')
+ .toLowerCase()
+ .trim()
+ ] ?? ASPECT_HINTS.landscape
+ )
}
function isInlineSrc(path: string): boolean {
diff --git a/apps/desktop/src/components/chat/image-generation-placeholder.tsx b/apps/desktop/src/components/chat/image-generation-placeholder.tsx
index 228f183f652..b29434efe22 100644
--- a/apps/desktop/src/components/chat/image-generation-placeholder.tsx
+++ b/apps/desktop/src/components/chat/image-generation-placeholder.tsx
@@ -41,7 +41,11 @@ const parseColor = (value: string, fallback: Rgb): Rgb => {
const srgb = v.match(/color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i)
return srgb
- ? { r: Math.round(Number(srgb[1]) * 255), g: Math.round(Number(srgb[2]) * 255), b: Math.round(Number(srgb[3]) * 255) }
+ ? {
+ r: Math.round(Number(srgb[1]) * 255),
+ g: Math.round(Number(srgb[2]) * 255),
+ b: Math.round(Number(srgb[3]) * 255)
+ }
: fallback
}
@@ -275,7 +279,10 @@ export const DiffusionCanvas: FC = () => {
// Re-resolve when the theme repaints (`applyTheme` toggles `.dark` and
// rewrites inline custom props on the root) instead of per animation frame.
const observer = new MutationObserver(sync)
- observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-hermes-mode'] })
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['class', 'style', 'data-hermes-mode']
+ })
return () => {
observer.disconnect()
diff --git a/apps/desktop/src/components/chat/status-row.tsx b/apps/desktop/src/components/chat/status-row.tsx
index af77fc910ce..074417558f7 100644
--- a/apps/desktop/src/components/chat/status-row.tsx
+++ b/apps/desktop/src/components/chat/status-row.tsx
@@ -53,9 +53,7 @@ export function StatusRow({
role={onActivate ? 'button' : undefined}
tabIndex={onActivate ? 0 : undefined}
>
- {leading !== undefined && (
- {leading}
- )}
+ {leading !== undefined && {leading}}
{children}
{trailing && (
+
{text}
diff --git a/apps/desktop/src/components/chat/zoomable-image.tsx b/apps/desktop/src/components/chat/zoomable-image.tsx
index 243f9f3415a..1ec201fd6d3 100644
--- a/apps/desktop/src/components/chat/zoomable-image.tsx
+++ b/apps/desktop/src/components/chat/zoomable-image.tsx
@@ -89,7 +89,12 @@ export function ImageLightbox({
onClick={() => onOpenChange(false)}
src={src}
/>
-
+
diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx
index 0341dc5675f..0aa75668b2d 100644
--- a/apps/desktop/src/components/desktop-install-overlay.tsx
+++ b/apps/desktop/src/components/desktop-install-overlay.tsx
@@ -354,9 +354,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
{copy.oneTimeTitle}
-
- {copy.unsupportedDesc(platformLabel)}
-
+
{copy.unsupportedDesc(platformLabel)}
{copy.installCommand}
@@ -423,9 +421,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
{failed ? copy.failedTitle : state.active ? copy.settingUpTitle : copy.finishingTitle}
-
- {failed ? copy.failedDesc : copy.activeDesc}
-
+
{failed ? copy.failedDesc : copy.activeDesc}
{/* Scrollable middle: progress, stages, error block, log */}
@@ -490,9 +486,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
>
{logOpen ?
:
}
{logOpen ? copy.hideOutput : copy.showOutput}
-
- ({copy.lines(state.log.length)})
-
+
({copy.lines(state.log.length)})
{logOpen && (
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
index eea24677ab9..7ea1c11ffb4 100644
--- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx
+++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
@@ -10,16 +10,7 @@ import { Input } from '@/components/ui/input'
import { Loader } from '@/components/ui/loader'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
-import {
- Check,
- ChevronDown,
- ChevronLeft,
- ChevronRight,
- ExternalLink,
- KeyRound,
- Loader2,
- Terminal
-} from '@/lib/icons'
+import { Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Terminal } from '@/lib/icons'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { cn } from '@/lib/utils'
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
@@ -216,8 +207,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
return
}
- const reduce =
- typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
+ const reduce = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (reduce) {
confirmOnboardingModel(ctx)
@@ -522,13 +512,7 @@ function ChooseLaterLink() {
const { t } = useI18n()
return (
-
dismissFirstRunOnboarding()}
- size="xs"
- type="button"
- variant="text"
- >
+ dismissFirstRunOnboarding()} size="xs" type="button" variant="text">
{t.onboarding.chooseLater}
)
@@ -650,20 +634,13 @@ export function ApiKeyForm({
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
- onSave: (
- envKey: string,
- value: string,
- name: string,
- apiKey?: string
- ) => Promise<{ message?: string; ok: boolean }>
+ onSave: (envKey: string, value: string, name: string, apiKey?: string) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const { t } = useI18n()
- const [option, setOption] = useState(
- () => options.find(o => o.envKey === initialEnvKey) ?? options[0]
- )
+ const [option, setOption] = useState(() => options.find(o => o.envKey === initialEnvKey) ?? options[0])
const [value, setValue] = useState('')
// Optional endpoint API key, only used by the local / custom endpoint option
@@ -731,13 +708,7 @@ export function ApiKeyForm({
return (
{canGoBack ? (
-
+
{t.onboarding.backToSignIn}
@@ -837,9 +808,7 @@ function FlowPanel({
}
if (flow.status === 'success') {
- return (
-
- )
+ return
}
if (flow.status === 'confirming_model') {
diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx
index 88dad33b1bd..e5e49315985 100644
--- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx
+++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx
@@ -54,13 +54,19 @@ afterEach(cleanup)
// "Lost connection…" copy doesn't read as a false positive.
const isConnectingShown = () =>
screen.queryAllByText((_, el) => /^CONN[/\\|\-_=+<>~:*A-Z]*$/.test(el?.textContent?.trim() ?? '')).length > 0
+
const isRecoveryShown = () =>
Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i))
describe('connecting overlay vs recovery surface', () => {
it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => {
// failDesktopBoot() ran: error set, gateway never opened.
- $desktopBoot.set({ ...$desktopBoot.get(), error: 'Hermes backend did not become ready', running: false, visible: true })
+ $desktopBoot.set({
+ ...$desktopBoot.get(),
+ error: 'Hermes backend did not become ready',
+ running: false,
+ visible: true
+ })
setGatewayState('error')
render(
@@ -78,12 +84,14 @@ describe('connecting overlay vs recovery surface', () => {
it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => {
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
setGatewayState('open')
+
const { rerender } = render(
<>
>
)
+
expect(isConnectingShown()).toBe(false)
// 2. The remote VPS socket drops (sleep/wake, remote restart, network).
diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx
index 11c83f7f293..a4e77a6d9ed 100644
--- a/apps/desktop/src/components/model-picker.tsx
+++ b/apps/desktop/src/components/model-picker.tsx
@@ -108,12 +108,7 @@ export function ModelPickerDialog({
-
+
{!loading && !error && {copy.noModels}}
{model}
- {locked && {copy.pro}}
+ {locked && (
+ {copy.pro}
+ )}
)
diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx
index 2558d27f93f..80429678d3d 100644
--- a/apps/desktop/src/components/notifications.tsx
+++ b/apps/desktop/src/components/notifications.tsx
@@ -83,7 +83,13 @@ export function NotificationStack() {
{expanded && olderNotifications.map(n => )}
{overflowCount > 0 && (
-
setExpanded(v => !v)} size="xs" type="button" variant="text">
+ setExpanded(v => !v)}
+ size="xs"
+ type="button"
+ variant="text"
+ >
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx
index 43df930a5bc..31a3f43c873 100644
--- a/apps/desktop/src/components/pane-shell/pane-shell.tsx
+++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx
@@ -264,7 +264,13 @@ export function PaneShell({ children, className, style }: PaneShellProps) {
tracks.push(track)
cssVars[`--pane-${pane.id}-width`] = track
const gridRow = open && paneSide === bottomRailSide ? '1 / 2' : '1 / -1'
- paneById.set(pane.id, { open, side: paneSide, gridColumn: `${column} / ${column + 1}`, gridRow, bottomRow: false })
+ paneById.set(pane.id, {
+ open,
+ side: paneSide,
+ gridColumn: `${column} / ${column + 1}`,
+ gridRow,
+ bottomRow: false
+ })
column++
}
@@ -282,7 +288,13 @@ export function PaneShell({ children, className, style }: PaneShellProps) {
// Place every bottom-row pane: span its rail's columns on the second row.
for (const pane of bottomRowPanes) {
const gridColumn = pane.side === 'left' ? `1 / ${mainColumn}` : `${mainColumn + 1} / -1`
- paneById.set(pane.id, { open: pane === activeBottomRow, side: pane.side, gridColumn, gridRow: '2 / 3', bottomRow: true })
+ paneById.set(pane.id, {
+ open: pane === activeBottomRow,
+ side: pane.side,
+ gridColumn,
+ gridRow: '2 / 3',
+ bottomRow: true
+ })
}
// Always emit explicit rows so `grid-row: 1 / -1` (full-height) resolves
@@ -292,7 +304,13 @@ export function PaneShell({ children, className, style }: PaneShellProps) {
? `minmax(0,1fr) ${heightTrackForPane(activeBottomRow, paneStates)}`
: 'minmax(0,1fr)'
- return { cssVars, gridTemplate: tracks.join(' '), gridTemplateRows, mainColumn, paneById } satisfies PaneShellContextValue & {
+ return {
+ cssVars,
+ gridTemplate: tracks.join(' '),
+ gridTemplateRows,
+ mainColumn,
+ paneById
+ } satisfies PaneShellContextValue & {
cssVars: Record
gridTemplate: string
gridTemplateRows: string
@@ -349,6 +367,7 @@ export function Pane({
// hover/focus instead of hiding them. Honors any persisted resize width.
const overlayActive = !open && hoverReveal && !disabled
const override = resizable ? paneStates[id]?.widthOverride : undefined
+
// Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins,
// else the persisted resize override, else the docked width.
const overlayWidth =
diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx
index b82d07ee717..a5745c00e24 100644
--- a/apps/desktop/src/components/pet/floating-pet.tsx
+++ b/apps/desktop/src/components/pet/floating-pet.tsx
@@ -142,13 +142,17 @@ export function FloatingPet() {
if (active) {
try {
const meta = await requestGateway('pet.info.meta', { profile: petProfile() })
+
if (cancelled || !meta) {
return
}
+
if (!meta.enabled) {
setPetInfo({ enabled: false })
+
return
}
+
if (samePetRevision($petInfo.get(), meta)) {
return
}
@@ -161,6 +165,7 @@ export function FloatingPet() {
if (!cancelled && next) {
const current = $petInfo.get()
+
if (
next.enabled &&
current.enabled &&
@@ -172,6 +177,7 @@ export function FloatingPet() {
) {
return
}
+
setPetInfo(next)
}
} catch {
@@ -350,6 +356,7 @@ export function FloatingPet() {
(info.frameW ?? 192) * next,
(info.frameH ?? 208) * next
)
+
persistString(POSITION_KEY, JSON.stringify(at))
return at
@@ -357,6 +364,7 @@ export function FloatingPet() {
},
[requestGateway, info.frameW, info.frameH]
)
+
usePetZoomGesture(containerRef, onScale, active && !overlayActive)
// While popped out, the desktop overlay window owns the mascot — hide the
@@ -396,7 +404,10 @@ export function FloatingPet() {
zIndex: 0
}}
/>
-
diff --git a/apps/desktop/src/components/pet/pet-bubble.tsx b/apps/desktop/src/components/pet/pet-bubble.tsx
index 3d3c8109681..cd843f04b35 100644
--- a/apps/desktop/src/components/pet/pet-bubble.tsx
+++ b/apps/desktop/src/components/pet/pet-bubble.tsx
@@ -29,10 +29,32 @@ interface Spec {
// Keep them short — the bubble is tiny and never wraps.
const SPECS: Partial> = {
run: {
- lines: ['working…', 'on it…', 'crunching…', 'tinkering…', 'cooking…', 'in the weeds…', 'wiring it up…', 'making moves…', 'heads down…', 'hammering away…']
+ lines: [
+ 'working…',
+ 'on it…',
+ 'crunching…',
+ 'tinkering…',
+ 'cooking…',
+ 'in the weeds…',
+ 'wiring it up…',
+ 'making moves…',
+ 'heads down…',
+ 'hammering away…'
+ ]
},
review: {
- lines: ['thinking…', 'reading…', 'reviewing…', 'pondering…', 'connecting dots…', 'sizing it up…', 'tracing it…', 'mulling…', 'scheming…', 'hmm…']
+ lines: [
+ 'thinking…',
+ 'reading…',
+ 'reviewing…',
+ 'pondering…',
+ 'connecting dots…',
+ 'sizing it up…',
+ 'tracing it…',
+ 'mulling…',
+ 'scheming…',
+ 'hmm…'
+ ]
},
failed: {
glyph: AlertCircle,
@@ -75,6 +97,7 @@ export function PetBubble() {
// it's actually the user's turn. Everything else maps to a mood spec.
const specKey: null | PetState =
state in SPECS ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null
+
const rotating = specKey === 'run' || specKey === 'review'
// Pick a fresh line on every mood change, then keep rotating (random, no
diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx
index fe2e0a6daaf..35d5f42f581 100644
--- a/apps/desktop/src/components/pet/pet-sprite.tsx
+++ b/apps/desktop/src/components/pet/pet-sprite.tsx
@@ -9,6 +9,7 @@ const DEFAULT_LOOP_MS = 1100
// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends
// the configured scale.
const DEFAULT_SCALE = 0.33
+
// Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy).
const DEFAULT_STATE_ROWS = [
'idle',
@@ -143,10 +144,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
const rowIndexForState = (s: PetState): number => {
for (const key of STATE_ALIASES[s] ?? [s]) {
const idx = rows.indexOf(key)
+
if (idx >= 0) {
return idx
}
}
+
return 0
}
@@ -166,10 +169,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
const resolveRow = (rowName: string): { row: number; count: number } => {
const row = rows.indexOf(rowName)
const state = ROW_TO_STATE[rowName]
+
const count = Math.max(
1,
framesByRow?.[rowName] ?? framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames
)
+
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
}
diff --git a/apps/desktop/src/components/pet/pet-star-shower.tsx b/apps/desktop/src/components/pet/pet-star-shower.tsx
index ad5552cd1ff..01b811e9594 100644
--- a/apps/desktop/src/components/pet/pet-star-shower.tsx
+++ b/apps/desktop/src/components/pet/pet-star-shower.tsx
@@ -42,6 +42,7 @@ function readAccent(el: HTMLElement): string {
function sparkle(ctx: CanvasRenderingContext2D, size: number, rot: number, color: string): void {
ctx.rotate(rot)
ctx.fillStyle = color
+
for (const [rx, ry] of [
[size, size * 0.26],
[size * 0.26, size]
@@ -54,6 +55,7 @@ function sparkle(ctx: CanvasRenderingContext2D, size: number, rot: number, color
ctx.closePath()
ctx.fill()
}
+
const core = Math.max(1, Math.round(size * 0.4))
ctx.fillStyle = '#fff'
ctx.fillRect(-core / 2, -core / 2, core, core)
@@ -66,9 +68,11 @@ export function PetStarShower() {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
const parent = canvas?.parentElement
+
if (!canvas || !ctx || !parent) {
return
}
+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
return
}
@@ -79,6 +83,7 @@ export function PetStarShower() {
let h = 0
let cx = 0
let cy = 0
+
const resize = () => {
const r = parent.getBoundingClientRect()
w = r.width
@@ -89,21 +94,34 @@ export function PetStarShower() {
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
+
resize()
const ro = new ResizeObserver(resize)
ro.observe(parent)
const pick = () => (Math.random() < GOLD_MIX ? GOLD : Math.random() < 0.5 ? accent : '#ffffff')
const stars: Star[] = []
+
for (let i = 0; i < BURST; i++) {
const a = Math.random() * Math.PI * 2
const sp = VELOCITY * (0.4 + Math.random() * 0.7)
stars.push({
- x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
- size: 3.5 + Math.random() * 5.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 8,
- phase: 0, twinkle: 0, life: 0, ttl: 0.8 + Math.random() * 0.7, color: pick(), rise: false
+ x: cx,
+ y: cy,
+ vx: Math.cos(a) * sp,
+ vy: Math.sin(a) * sp,
+ size: 3.5 + Math.random() * 5.5,
+ rot: Math.random() * 6.28,
+ vrot: (Math.random() - 0.5) * 8,
+ phase: 0,
+ twinkle: 0,
+ life: 0,
+ ttl: 0.8 + Math.random() * 0.7,
+ color: pick(),
+ rise: false
})
}
+
const rays = { life: 0, ttl: 0.9, rot: Math.random() * 6.28 }
let raf = 0
@@ -118,14 +136,23 @@ export function PetStarShower() {
const dt = Math.min(0.05, ms / 1000)
const decay = Math.pow(DECAY, dt * 60)
acc += ms
+
if (acc >= MOTE_MS && stars.length < 40) {
acc = 0
stars.push({
- x: cx + (Math.random() - 0.5) * w * 0.85, y: cy + Math.random() * h * 0.25,
- vx: (Math.random() - 0.5) * 14, vy: -(14 + Math.random() * 26),
- size: 2.5 + Math.random() * 3.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 2,
- phase: Math.random() * 6.28, twinkle: 5 + Math.random() * 4, life: 0, ttl: 1.2 + Math.random(),
- color: pick(), rise: true
+ x: cx + (Math.random() - 0.5) * w * 0.85,
+ y: cy + Math.random() * h * 0.25,
+ vx: (Math.random() - 0.5) * 14,
+ vy: -(14 + Math.random() * 26),
+ size: 2.5 + Math.random() * 3.5,
+ rot: Math.random() * 6.28,
+ vrot: (Math.random() - 0.5) * 2,
+ phase: Math.random() * 6.28,
+ twinkle: 5 + Math.random() * 4,
+ life: 0,
+ ttl: 1.2 + Math.random(),
+ color: pick(),
+ rise: true
})
}
@@ -137,6 +164,7 @@ export function PetStarShower() {
rays.life += dt
rays.rot += dt * 0.6
const t = rays.life / rays.ttl
+
if (t >= 1) {
raysAlive = false
} else {
@@ -144,6 +172,7 @@ export function PetStarShower() {
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(rays.rot)
+
for (let i = 0; i < RAY_COUNT; i++) {
ctx.rotate((Math.PI * 2) / RAY_COUNT)
const a = (1 - t) * 0.3 * (i % 2 ? 0.65 : 1)
@@ -159,6 +188,7 @@ export function PetStarShower() {
ctx.closePath()
ctx.fill()
}
+
ctx.restore()
}
}
@@ -166,6 +196,7 @@ export function PetStarShower() {
for (let i = stars.length - 1; i >= 0; i--) {
const s = stars[i]
s.life += dt
+
if (s.rise) {
s.vy += 7 * dt
s.phase += s.twinkle * dt
@@ -173,16 +204,21 @@ export function PetStarShower() {
s.vx *= decay
s.vy = s.vy * decay + GRAVITY * dt
}
+
s.x += s.vx * dt
s.y += s.vy * dt
s.rot += s.vrot * dt
+
if (s.life >= s.ttl || s.y < -12) {
stars.splice(i, 1)
+
continue
}
+
const fade = s.rise
? Math.min(1, s.life * 5, (s.ttl - s.life) * 3) * (0.45 + 0.55 * Math.abs(Math.sin(s.phase)))
: Math.min(1, (s.ttl - s.life) * 3)
+
ctx.save()
ctx.globalAlpha = fade
ctx.translate(Math.round(s.x), Math.round(s.y))
@@ -192,6 +228,7 @@ export function PetStarShower() {
ctx.globalCompositeOperation = 'source-over'
}
+
raf = requestAnimationFrame(tick)
return () => {
diff --git a/apps/desktop/src/components/pet/pixel-egg-sprite.tsx b/apps/desktop/src/components/pet/pixel-egg-sprite.tsx
index 7d3b6fa55a7..5c4253904da 100644
--- a/apps/desktop/src/components/pet/pixel-egg-sprite.tsx
+++ b/apps/desktop/src/components/pet/pixel-egg-sprite.tsx
@@ -42,6 +42,7 @@ const lerp = (a: number, b: number, t: number) => a + (b - a) * t
// the cutoff it's the flat outline; above, a SHADOW→HIGHLIGHT ramp.
const CREME_LUT = (() => {
const lut = new Uint8ClampedArray(256 * 3)
+
for (let g = 0; g < 256; g++) {
const dark = g < OUTLINE_CUTOFF
const t = dark ? 0 : (g - OUTLINE_CUTOFF) / (255 - OUTLINE_CUTOFF)
@@ -49,6 +50,7 @@ const CREME_LUT = (() => {
const to = dark ? OUTLINE : HIGHLIGHT
lut.set([lerp(from[0], to[0], t), lerp(from[1], to[1], t), lerp(from[2], to[2], t)], g * 3)
}
+
return lut
})()
@@ -59,17 +61,21 @@ function loadSheet(): Promise {
if (_sheet?.complete) {
return Promise.resolve(_sheet)
}
+
if (!_sheetLoading) {
_sheetLoading = new Promise((resolve, reject) => {
const img = new Image()
+
img.onload = () => {
_sheet = img
resolve(img)
}
+
img.onerror = reject
img.src = eggSheetUrl
})
}
+
return _sheetLoading
}
@@ -97,6 +103,7 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
+
if (!canvas || !ctx) {
return
}
@@ -129,20 +136,24 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
if (!sheet || !offCtx) {
return
}
+
offCtx.clearRect(0, 0, FRAME, FRAME)
offCtx.imageSmoothingEnabled = false
offCtx.drawImage(sheet, 0, frame * FRAME, FRAME, FRAME, 0, 0, FRAME, FRAME)
const img = offCtx.getImageData(0, 0, FRAME, FRAME)
const d = img.data
+
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] === 0) {
continue
}
+
const g = d[i] * 3
d[i] = CREME_LUT[g]
d[i + 1] = CREME_LUT[g + 1]
d[i + 2] = CREME_LUT[g + 2]
}
+
offCtx.putImageData(img, 0, 0)
ctx.clearRect(0, 0, dim, dim)
@@ -161,6 +172,7 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
const tick = (now: number) => {
raf = requestAnimationFrame(tick)
+
if (!sheet) {
return
}
@@ -169,22 +181,29 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
if (!lastHatch) {
lastHatch = now
render(HATCH_START)
+
return
}
+
if (now - lastHatch < frameMs) {
return
}
+
lastHatch = now
const frame = Math.min(HATCH_START + step, lastFrame)
render(frame)
+
if (frame >= lastFrame) {
if (!finished) {
finished = true
onDoneRef.current?.()
}
+
return // hold the cracked-open last frame
}
+
step += 1
+
return
}
@@ -192,8 +211,10 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
if (!nextAt) {
render(0)
nextAt = now + firstDelay // staggered first bounce, per slot
+
return
}
+
if (now < nextAt) {
return
}
@@ -203,16 +224,20 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
step = 0
render(0)
nextAt = now + frameMs
+
return
}
step += 1
+
if (step >= BOUNCE_FRAMES) {
resting = true
render(0)
nextAt = now + restMs()
+
return
}
+
render(step)
nextAt = now + frameMs
}
diff --git a/apps/desktop/src/components/session-picker.tsx b/apps/desktop/src/components/session-picker.tsx
index 67012d9a3f0..38600532ba1 100644
--- a/apps/desktop/src/components/session-picker.tsx
+++ b/apps/desktop/src/components/session-picker.tsx
@@ -24,12 +24,7 @@ interface SessionPickerDialogProps {
* sessions only, so `/resume` feels first-class instead of falling through to
* the headless slash worker (which can't render the picker).
*/
-export function SessionPickerDialog({
- activeStoredSessionId,
- onOpenChange,
- onResume,
- open
-}: SessionPickerDialogProps) {
+export function SessionPickerDialog({ activeStoredSessionId, onOpenChange, onResume, open }: SessionPickerDialogProps) {
const { t } = useI18n()
const [search, setSearch] = useState('')
@@ -57,11 +52,7 @@ export function SessionPickerDialog({
>
{t.commandCenter.sections.sessions}
-
+
{t.commandCenter.noResults}
{title}
- {preview ? (
- {preview}
- ) : null}
+ {preview ? {preview} : null}
void run()} variant={destructive ? 'destructive' : 'default'}>
-
+
diff --git a/apps/desktop/src/components/ui/copy-button.tsx b/apps/desktop/src/components/ui/copy-button.tsx
index d799eac5482..f7eed235d02 100644
--- a/apps/desktop/src/components/ui/copy-button.tsx
+++ b/apps/desktop/src/components/ui/copy-button.tsx
@@ -158,6 +158,7 @@ export function CopyButton({
const feedbackLabel =
status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
+
const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
if (appearance === 'menu-item' || appearance === 'context-menu-item') {
diff --git a/apps/desktop/src/components/ui/split-button.tsx b/apps/desktop/src/components/ui/split-button.tsx
index 8368ae4bcb5..904796dd4f5 100644
--- a/apps/desktop/src/components/ui/split-button.tsx
+++ b/apps/desktop/src/components/ui/split-button.tsx
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
-import type { buttonVariants } from './button';
+import type { buttonVariants } from './button'
import { Button } from './button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './dropdown-menu'
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index e0c3226d3fa..86b861f35f5 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -146,10 +146,7 @@ declare global {
createPr: (repoPath: string) => Promise<{ url: string }>
}
// Repo-first discovery: scan bounded roots for git repos (depth-capped).
- scanRepos: (
- roots: string[],
- options?: { maxDepth?: number }
- ) => Promise<{ root: string; label: string }[]>
+ scanRepos: (roots: string[], options?: { maxDepth?: number }) => Promise<{ root: string; label: string }[]>
}
terminal: {
dispose: (id: string) => Promise
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index 854ca35328d..8e0656a9e7a 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -362,10 +362,7 @@ export function getMemoryProviderConfig(provider: string): Promise
-): Promise<{ ok: boolean }> {
+export function saveMemoryProviderConfig(provider: string, values: Record): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`,
method: 'PUT',
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 05adfa80803..a5083d80252 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -1550,7 +1550,8 @@ export const en: Translations = {
openPr: 'Open PR',
ghMissing: 'Install the GitHub CLI (gh) and sign in to open PRs',
agentShip: 'Ask Hermes to open PR',
- agentShipPrompt: 'Review the current changes, commit them with a clear conventional-commit message, push the branch, and open a pull request.',
+ agentShipPrompt:
+ 'Review the current changes, commit them with a clear conventional-commit message, push the branch, and open a pull request.',
newBranch: 'New branch',
branchOffFrom: base => `New branch from ${base}`,
switchTo: branch => `Switch to ${branch}`,
@@ -1907,7 +1908,8 @@ export const en: Translations = {
unsavedChanges: 'Unsaved changes',
saveFailed: message => `Couldn't save: ${message}`,
diskChangedTitle: 'File changed on disk',
- diskChangedBody: 'This file changed since you opened it. Overwrite it with your version, or discard your edits and reload?',
+ diskChangedBody:
+ 'This file changed since you opened it. Overwrite it with your version, or discard your edits and reload?',
overwrite: 'Overwrite',
discardReload: 'Discard & reload',
console: {
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 518b9a3b89c..103d05db90f 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1438,7 +1438,8 @@ export const ja = defineLocale({
copyPath: 'パスをコピー',
removeFromSidebar: 'サイドバーから削除',
createFailed: 'プロジェクトを作成できませんでした',
- deleteConfirm: 'Hermes から保存済みプロジェクトを削除します。ファイル・git リポジトリ・ワークツリーはそのまま残ります。',
+ deleteConfirm:
+ 'Hermes から保存済みプロジェクトを削除します。ファイル・git リポジトリ・ワークツリーはそのまま残ります。',
startWork: '新しいワークツリー',
newWorktreeTitle: '新しいワークツリー',
newWorktreeDesc: 'このワークツリーのブランチ名を入力してください。',
@@ -2031,7 +2032,8 @@ export const ja = defineLocale({
unsavedChanges: '未保存の変更',
saveFailed: message => `保存できませんでした:${message}`,
diskChangedTitle: 'ファイルがディスク上で変更されました',
- diskChangedBody: 'このファイルは開いてから変更されています。あなたの版で上書きするか、編集を破棄して再読み込みしますか?',
+ diskChangedBody:
+ 'このファイルは開いてから変更されています。あなたの版で上書きするか、編集を破棄して再読み込みしますか?',
overwrite: '上書き',
discardReload: '破棄して再読み込み',
console: {
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 426b55ea70a..00ecdb5884e 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1409,7 +1409,8 @@ export const zhHant = defineLocale({
noBranches: '找不到分支',
removeWorktree: '移除工作樹',
removeWorktreeFailed: '無法移除工作樹(有未提交的變更?)',
- removeWorktreeConfirm: '從 git 中移除(刪除工作樹目錄,但保留分支),或僅從側邊欄隱藏該軌道並將工作樹保留在磁碟上。',
+ removeWorktreeConfirm:
+ '從 git 中移除(刪除工作樹目錄,但保留分支),或僅從側邊欄隱藏該軌道並將工作樹保留在磁碟上。',
removeWorktreeDirty: '此工作樹有未提交的變更。強制移除(捨棄這些變更),或僅隱藏軌道並保留在磁碟上。',
forceRemove: '強制移除',
enter: label => `開啟 ${label}`
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index c67c81826dc..328efb90f7d 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1516,7 +1516,8 @@ export const zh: Translations = {
noBranches: '未找到分支',
removeWorktree: '移除工作树',
removeWorktreeFailed: '无法移除工作树(存在未提交更改?)',
- removeWorktreeConfirm: '从 git 中移除(删除工作树目录,但保留分支),或仅从侧边栏隐藏该泳道并将工作树保留在磁盘上。',
+ removeWorktreeConfirm:
+ '从 git 中移除(删除工作树目录,但保留分支),或仅从侧边栏隐藏该泳道并将工作树保留在磁盘上。',
removeWorktreeDirty: '此工作树有未提交的更改。强制移除(丢弃这些更改),或仅隐藏泳道并保留在磁盘上。',
forceRemove: '强制移除',
enter: label => `打开 ${label}`,
diff --git a/apps/desktop/src/lib/chat-runtime.test.ts b/apps/desktop/src/lib/chat-runtime.test.ts
index 46ebcfefb1a..9d30dfb1c38 100644
--- a/apps/desktop/src/lib/chat-runtime.test.ts
+++ b/apps/desktop/src/lib/chat-runtime.test.ts
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest'
import type { ComposerAttachment } from '@/store/composer'
-import { attachmentDisplayText, coerceThinkingText, optimisticAttachmentRef, parseCommandDispatch } from './chat-runtime'
+import {
+ attachmentDisplayText,
+ coerceThinkingText,
+ optimisticAttachmentRef,
+ parseCommandDispatch
+} from './chat-runtime'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts
index fbf0ebdf8c0..9cd0c923d1d 100644
--- a/apps/desktop/src/lib/chat-runtime.ts
+++ b/apps/desktop/src/lib/chat-runtime.ts
@@ -252,9 +252,7 @@ export function parseCommandDispatch(raw: unknown): CommandDispatchResponse | nu
return typeof row.message === 'string' ? { type: 'send', message: row.message, notice: str(row.notice) } : null
case 'prefill':
- return typeof row.message === 'string'
- ? { type: 'prefill', message: row.message, notice: str(row.notice) }
- : null
+ return typeof row.message === 'string' ? { type: 'prefill', message: row.message, notice: str(row.notice) } : null
default:
return null
diff --git a/apps/desktop/src/lib/completion-sound.ts b/apps/desktop/src/lib/completion-sound.ts
index b8f95c6f002..4457d912b78 100644
--- a/apps/desktop/src/lib/completion-sound.ts
+++ b/apps/desktop/src/lib/completion-sound.ts
@@ -15,7 +15,8 @@ function getCtx(): AudioContext | null {
try {
if (!ctx) {
- const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
+ const Ctor =
+ window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) {
return null
diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts
index c45ffb6745a..d9c999773f4 100644
--- a/apps/desktop/src/lib/desktop-fs.test.ts
+++ b/apps/desktop/src/lib/desktop-fs.test.ts
@@ -17,12 +17,28 @@ const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local'
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
const gitRoot = vi.fn(async () => '/local')
const selectPaths = vi.fn(async () => ['/local'])
+
const api = vi.fn(async ({ path }: { path: string }) => {
- if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
- if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
- if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
- if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
- if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
+ if (path.startsWith('/api/fs/list?')) {
+ return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
+ }
+
+ if (path.startsWith('/api/fs/read-text?')) {
+ return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
+ }
+
+ if (path.startsWith('/api/fs/read-data-url?')) {
+ return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
+ }
+
+ if (path.startsWith('/api/fs/git-root?')) {
+ return { root: '/remote' }
+ }
+
+ if (path === '/api/fs/default-cwd') {
+ return { cwd: '/backend/project', branch: 'main' }
+ }
+
throw new Error(`unexpected path ${path}`)
})
@@ -55,7 +71,9 @@ describe('desktop filesystem facade', () => {
it('uses local Electron filesystem methods in local mode', async () => {
$connection.set({ mode: 'local' } as never)
- await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
+ await expect(readDesktopDir('/work')).resolves.toEqual({
+ entries: [{ name: 'local', path: '/local', isDirectory: true }]
+ })
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts
index 5cc11e00424..c5e28819557 100644
--- a/apps/desktop/src/lib/desktop-slash-commands.ts
+++ b/apps/desktop/src/lib/desktop-slash-commands.ts
@@ -99,9 +99,19 @@ const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface =>
const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// Local client actions
{ name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') },
- { name: '/branch', description: 'Branch the latest message into a new chat', aliases: ['/fork'], surface: action('branch') },
+ {
+ name: '/branch',
+ description: 'Branch the latest message into a new chat',
+ aliases: ['/fork'],
+ surface: action('branch')
+ },
{ name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') },
- { name: '/handoff', description: 'Hand off this session to a messaging platform', surface: action('handoff'), args: true },
+ {
+ name: '/handoff',
+ description: 'Hand off this session to a messaging platform',
+ surface: action('handoff'),
+ args: true
+ },
{ name: '/profile', description: 'Switch the active Hermes profile', surface: action('profile') },
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
{ name: '/title', description: 'Rename the current session', surface: action('title') },
@@ -124,14 +134,29 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
},
// Backend-executed commands that render useful inline output
- { name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() },
+ {
+ name: '/agents',
+ description: 'Show active desktop sessions and running tasks',
+ aliases: ['/tasks'],
+ surface: exec()
+ },
{ name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() },
{ name: '/compress', description: 'Compress this conversation context', surface: exec() },
{ name: '/debug', description: 'Create a debug report', surface: exec() },
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
- { name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), args: true },
- { name: '/hatch', description: 'Generate a new pet (opens the pet generator)', aliases: ['/generate-pet'], surface: action('hatch') },
+ {
+ name: '/pet',
+ description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)',
+ surface: action('pet'),
+ args: true
+ },
+ {
+ name: '/hatch',
+ description: 'Generate a new pet (opens the pet generator)',
+ aliases: ['/generate-pet'],
+ surface: action('hatch')
+ },
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
@@ -153,10 +178,37 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// per reason beats 40 identical object literals.
const NO_DESKTOP_SURFACE: Record = {
terminal: [
- '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
- '/exit', '/footer', '/gateway', '/history', '/image', '/indicator', '/logs',
- '/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
- '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
+ '/busy',
+ '/clear',
+ '/compact',
+ '/config',
+ '/copy',
+ '/cron',
+ '/details',
+ '/exit',
+ '/footer',
+ '/gateway',
+ '/history',
+ '/image',
+ '/indicator',
+ '/logs',
+ '/mouse',
+ '/paste',
+ '/platforms',
+ '/plugins',
+ '/quit',
+ '/redraw',
+ '/reload',
+ '/restart',
+ '/sb',
+ '/set-home',
+ '/sethome',
+ '/snap',
+ '/snapshot',
+ '/statusbar',
+ '/toolsets',
+ '/update',
+ '/verbose'
],
messaging: ['/approve', '/deny'],
settings: ['/skills', '/pets'],
diff --git a/apps/desktop/src/lib/embedded-images.ts b/apps/desktop/src/lib/embedded-images.ts
index cd68ce68292..9b75eeae140 100644
--- a/apps/desktop/src/lib/embedded-images.ts
+++ b/apps/desktop/src/lib/embedded-images.ts
@@ -106,7 +106,10 @@ function embeddedImageRemovalRange(text: string, dataStart: number, dataEnd: num
}
function normalizeCleanedText(text: string): string {
- return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
+ return text
+ .replace(/[ \t]+\n/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
}
export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
diff --git a/apps/desktop/src/lib/excluded-paths.ts b/apps/desktop/src/lib/excluded-paths.ts
index 4291249ed8b..ed21988a1e9 100644
--- a/apps/desktop/src/lib/excluded-paths.ts
+++ b/apps/desktop/src/lib/excluded-paths.ts
@@ -41,5 +41,4 @@ export const ALWAYS_EXCLUDED = new Set([
// True when any segment of a relative path is excluded (review rows like
// `node_modules/.bin/foo` or a bare `.DS_Store`). Handles `/` and `\`.
-export const isExcludedPath = (relPath: string): boolean =>
- relPath.split(/[/\\]/).some(seg => ALWAYS_EXCLUDED.has(seg))
+export const isExcludedPath = (relPath: string): boolean => relPath.split(/[/\\]/).some(seg => ALWAYS_EXCLUDED.has(seg))
diff --git a/apps/desktop/src/lib/generated-images.test.ts b/apps/desktop/src/lib/generated-images.test.ts
index 802dc213fd7..15784b4d2e9 100644
--- a/apps/desktop/src/lib/generated-images.test.ts
+++ b/apps/desktop/src/lib/generated-images.test.ts
@@ -34,9 +34,9 @@ describe('stripGeneratedImageEchoes', () => {
})
it('removes media links for generated local image paths', () => {
- expect(
- stripGeneratedImageEchoes('Saved image: [Image: cat.png](#media:%2Ftmp%2Fcat.png)', ['/tmp/cat.png'])
- ).toBe('Saved image:')
+ expect(stripGeneratedImageEchoes('Saved image: [Image: cat.png](#media:%2Ftmp%2Fcat.png)', ['/tmp/cat.png'])).toBe(
+ 'Saved image:'
+ )
})
})
@@ -45,7 +45,12 @@ describe('generatedImageEchoSources', () => {
expect(
generatedImageEchoSources([
{
- result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
+ result: {
+ agent_visible_image: '/sandbox/cat.png',
+ host_image: '/host/cat.png',
+ image: '/host/cat.png',
+ success: true
+ },
toolName: 'image_generate',
type: 'tool-call'
}
@@ -59,11 +64,19 @@ describe('dedupeGeneratedImageEchoesInParts', () => {
expect(
dedupeGeneratedImageEchoesInParts([
{ text: 'Here is your peacock!  Enjoy.', type: 'text' },
- { result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, toolName: 'image_generate', type: 'tool-call' }
+ {
+ result: { host_image: '/host/p.png', image: '/host/p.png', success: true },
+ toolName: 'image_generate',
+ type: 'tool-call'
+ }
])
).toEqual([
{ text: 'Here is your peacock! Enjoy.', type: 'text' },
- { result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, toolName: 'image_generate', type: 'tool-call' }
+ {
+ result: { host_image: '/host/p.png', image: '/host/p.png', success: true },
+ toolName: 'image_generate',
+ type: 'tool-call'
+ }
])
})
@@ -72,14 +85,24 @@ describe('dedupeGeneratedImageEchoesInParts', () => {
dedupeGeneratedImageEchoesInParts([
{ text: '', type: 'text' },
{
- result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
+ result: {
+ agent_visible_image: '/sandbox/cat.png',
+ host_image: '/host/cat.png',
+ image: '/host/cat.png',
+ success: true
+ },
toolName: 'image_generate',
type: 'tool-call'
}
])
).toEqual([
{
- result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
+ result: {
+ agent_visible_image: '/sandbox/cat.png',
+ host_image: '/host/cat.png',
+ image: '/host/cat.png',
+ success: true
+ },
toolName: 'image_generate',
type: 'tool-call'
}
diff --git a/apps/desktop/src/lib/generated-images.ts b/apps/desktop/src/lib/generated-images.ts
index f1cab82028f..69b315738e5 100644
--- a/apps/desktop/src/lib/generated-images.ts
+++ b/apps/desktop/src/lib/generated-images.ts
@@ -82,9 +82,7 @@ export function stripGeneratedImageEchoes(text: string, sources: readonly string
return text
}
- let next = text
- .replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '')
- .replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '')
+ let next = text.replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '').replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '')
for (const source of unique([...sources])) {
next = next.replace(new RegExp(String.raw`(^|[\s([{])${regexEscape(source)}>?(?=$|[\s)\]},.!?])`, 'g'), '$1')
diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts
index a82b920ec7a..a1d4df8014f 100644
--- a/apps/desktop/src/lib/icons.ts
+++ b/apps/desktop/src/lib/icons.ts
@@ -46,8 +46,8 @@ import {
IconPhoto as ImageIcon,
IconInfoCircle as Info,
IconKey as KeyRound,
- IconLayoutDashboard as LayoutDashboard,
IconLayersIntersect2 as Layers3,
+ IconLayoutDashboard as LayoutDashboard,
IconLink as Link,
IconLink as Link2,
IconLink as LinkIcon,
@@ -89,8 +89,8 @@ import {
IconSettings2 as Settings2,
IconAdjustmentsHorizontal as SlidersHorizontal,
IconSquare as Square,
- IconPlayerStopFilled as StopFilled,
IconSteeringWheel as SteeringWheel,
+ IconPlayerStopFilled as StopFilled,
IconSun as Sun,
IconTerminal2 as Terminal,
IconTrash as Trash2,
@@ -155,8 +155,8 @@ export {
ImageIcon,
Info,
KeyRound,
- LayoutDashboard,
Layers3,
+ LayoutDashboard,
Link,
Link2,
LinkIcon,
@@ -198,8 +198,8 @@ export {
Settings2,
SlidersHorizontal,
Square,
- StopFilled,
SteeringWheel,
+ StopFilled,
Sun,
Terminal,
Trash2,
diff --git a/apps/desktop/src/lib/local-preview.ts b/apps/desktop/src/lib/local-preview.ts
index ede9a1cab97..58fd20e219e 100644
--- a/apps/desktop/src/lib/local-preview.ts
+++ b/apps/desktop/src/lib/local-preview.ts
@@ -115,6 +115,7 @@ async function enrichPreviewTarget(target: PreviewTarget | null): Promise {
it('formats display names consistently', () => {
diff --git a/apps/desktop/src/lib/pool.test.ts b/apps/desktop/src/lib/pool.test.ts
index 005900f33e0..15aafa46f2e 100644
--- a/apps/desktop/src/lib/pool.test.ts
+++ b/apps/desktop/src/lib/pool.test.ts
@@ -6,6 +6,7 @@ describe('mapPool', () => {
it('preserves input order regardless of completion order', async () => {
const out = await mapPool([30, 10, 20], 3, async ms => {
await new Promise(r => setTimeout(r, ms))
+
return ms
})
diff --git a/apps/desktop/src/lib/profile-color.ts b/apps/desktop/src/lib/profile-color.ts
index 289b3c99703..c804e7e4f42 100644
--- a/apps/desktop/src/lib/profile-color.ts
+++ b/apps/desktop/src/lib/profile-color.ts
@@ -32,10 +32,7 @@ export function profileColor(name: null | string | undefined): null | string {
// A profile's effective color: a user-picked override wins, else the
// deterministic hue. Default/empty stays neutral (null) regardless.
-export function resolveProfileColor(
- name: null | string | undefined,
- overrides: Record
-): null | string {
+export function resolveProfileColor(name: null | string | undefined, overrides: Record): null | string {
const key = (name ?? '').trim()
if (!key || key === 'default') {
diff --git a/apps/desktop/src/lib/project-idea-templates.ts b/apps/desktop/src/lib/project-idea-templates.ts
index f56554035e9..3c0df88cb8b 100644
--- a/apps/desktop/src/lib/project-idea-templates.ts
+++ b/apps/desktop/src/lib/project-idea-templates.ts
@@ -13,93 +13,93 @@ export const PROJECT_IDEA_TEMPLATES: ProjectIdeaTemplate[] = [
{
emoji: '🎮',
label: 'Game jam',
- idea: 'A tiny browser game built in a weekend.\n\n- One core mechanic, juicy feedback\n- No build step — single HTML/JS file\n- Playable in under 60 seconds',
+ idea: 'A tiny browser game built in a weekend.\n\n- One core mechanic, juicy feedback\n- No build step — single HTML/JS file\n- Playable in under 60 seconds'
},
{
emoji: '📚',
label: 'Novel',
- idea: 'A novel-in-progress.\n\n- Track chapters, characters, and timeline\n- Daily word-count goal\n- Keep research notes beside the draft',
+ idea: 'A novel-in-progress.\n\n- Track chapters, characters, and timeline\n- Daily word-count goal\n- Keep research notes beside the draft'
},
{
emoji: '🤖',
label: 'Discord bot',
- idea: 'A Discord bot for a small community.\n\n- Slash commands + a fun daily ritual\n- Lightweight persistence\n- Deploy somewhere free',
+ idea: 'A Discord bot for a small community.\n\n- Slash commands + a fun daily ritual\n- Lightweight persistence\n- Deploy somewhere free'
},
{
emoji: '📊',
label: 'Data viz',
- idea: 'An interactive visualization of a dataset I care about.\n\n- Pick the dataset and the one question it answers\n- Clean → chart → annotate\n- Shareable as a single page',
+ idea: 'An interactive visualization of a dataset I care about.\n\n- Pick the dataset and the one question it answers\n- Clean → chart → annotate\n- Shareable as a single page'
},
{
emoji: '🎨',
label: 'Generative art',
- idea: 'A generative art piece.\n\n- One algorithm, lots of seeds\n- Export high-res stills\n- A gallery of the best outputs',
+ idea: 'A generative art piece.\n\n- One algorithm, lots of seeds\n- Export high-res stills\n- A gallery of the best outputs'
},
{
emoji: '🍳',
label: 'Recipe box',
- idea: 'A personal recipe collection.\n\n- Searchable by ingredient and mood\n- Scale servings on the fly\n- Auto-build a shopping list',
+ idea: 'A personal recipe collection.\n\n- Searchable by ingredient and mood\n- Scale servings on the fly\n- Auto-build a shopping list'
},
{
emoji: '🧪',
label: 'Research log',
- idea: 'A research notebook for an open question.\n\n- Log experiments, results, and dead ends\n- Cite sources inline\n- Weekly synthesis of what I learned',
+ idea: 'A research notebook for an open question.\n\n- Log experiments, results, and dead ends\n- Cite sources inline\n- Weekly synthesis of what I learned'
},
{
emoji: '💸',
label: 'Budget tracker',
- idea: 'A no-nonsense budget tracker.\n\n- Import transactions, tag them fast\n- Monthly burn vs. plan\n- One chart that tells the truth',
+ idea: 'A no-nonsense budget tracker.\n\n- Import transactions, tag them fast\n- Monthly burn vs. plan\n- One chart that tells the truth'
},
{
emoji: '🌱',
label: 'Habit tracker',
- idea: 'A habit tracker that actually sticks.\n\n- A handful of daily checkboxes\n- Streaks without guilt\n- A calm weekly review',
+ idea: 'A habit tracker that actually sticks.\n\n- A handful of daily checkboxes\n- Streaks without guilt\n- A calm weekly review'
},
{
emoji: '🗺️',
label: 'Trip planner',
- idea: 'A trip planner for an upcoming adventure.\n\n- Day-by-day itinerary\n- Map of pins + notes\n- Packing + budget checklist',
+ idea: 'A trip planner for an upcoming adventure.\n\n- Day-by-day itinerary\n- Map of pins + notes\n- Packing + budget checklist'
},
{
emoji: '🎵',
label: 'Music toy',
- idea: 'A little music-making toy.\n\n- One instrument or sequencer\n- Web Audio, no installs\n- Record + share a loop',
+ idea: 'A little music-making toy.\n\n- One instrument or sequencer\n- Web Audio, no installs\n- Record + share a loop'
},
{
emoji: '🧩',
label: 'Puzzle maker',
- idea: 'A generator for a puzzle I love.\n\n- Procedurally make solvable puzzles\n- Difficulty dial\n- Printable + playable',
+ idea: 'A generator for a puzzle I love.\n\n- Procedurally make solvable puzzles\n- Difficulty dial\n- Printable + playable'
},
{
emoji: '📝',
label: 'Digital garden',
- idea: 'A digital garden / personal wiki.\n\n- Atomic notes that link to each other\n- Grows over time, never "done"\n- Publish the public ones',
+ idea: 'A digital garden / personal wiki.\n\n- Atomic notes that link to each other\n- Grows over time, never "done"\n- Publish the public ones'
},
{
emoji: '🛰️',
label: 'API wrapper',
- idea: 'A clean wrapper around an API I keep reaching for.\n\n- Typed client + sensible defaults\n- One example per endpoint\n- Publish it',
+ idea: 'A clean wrapper around an API I keep reaching for.\n\n- Typed client + sensible defaults\n- One example per endpoint\n- Publish it'
},
{
emoji: '🏋️',
label: 'Workout plan',
- idea: 'A workout planner / logger.\n\n- Build a weekly split\n- Log sets fast on mobile\n- Track progress over months',
+ idea: 'A workout planner / logger.\n\n- Build a weekly split\n- Log sets fast on mobile\n- Track progress over months'
},
{
emoji: '🧠',
label: 'Flashcards',
- idea: 'A spaced-repetition flashcard app.\n\n- Quick card capture\n- Simple SM-2 scheduling\n- A daily review that fits in 5 minutes',
+ idea: 'A spaced-repetition flashcard app.\n\n- Quick card capture\n- Simple SM-2 scheduling\n- A daily review that fits in 5 minutes'
},
{
emoji: '✍️',
label: 'Screenplay',
- idea: 'A short screenplay.\n\n- Logline → beats → scenes\n- Proper format, distraction-free\n- A table read by the end',
+ idea: 'A short screenplay.\n\n- Logline → beats → scenes\n- Proper format, distraction-free\n- A table read by the end'
},
{
emoji: '🔭',
label: 'Learn-by-building',
- idea: "A project to learn a thing I've been avoiding.\n\n- Smallest real thing that teaches it\n- Notes on every gotcha\n- A writeup when it works",
- },
+ idea: "A project to learn a thing I've been avoiding.\n\n- Smallest real thing that teaches it\n- Notes on every gotcha\n- A writeup when it works"
+ }
]
// A shuffled slice of the pool — the pills shown at any moment.
diff --git a/apps/desktop/src/lib/runtime-readiness.test.ts b/apps/desktop/src/lib/runtime-readiness.test.ts
index 83a1d2a2bdd..87c9e6d2d5b 100644
--- a/apps/desktop/src/lib/runtime-readiness.test.ts
+++ b/apps/desktop/src/lib/runtime-readiness.test.ts
@@ -67,6 +67,7 @@ describe('interpretRuntimeReadiness', () => {
describe('fetchRuntimeReadinessSignals', () => {
it('scopes setup.runtime_check to the requested provider', async () => {
const calls: Array<{ method: string; params?: Record }> = []
+
const requestGateway = async (method: string, params?: Record) => {
calls.push({ method, params })
@@ -83,10 +84,7 @@ describe('fetchRuntimeReadinessSignals', () => {
await fetchRuntimeReadinessSignals(requestGateway, 'nous')
- expect(calls).toEqual([
- { method: 'setup.status' },
- { method: 'setup.runtime_check', params: { provider: 'nous' } }
- ])
+ expect(calls).toEqual([{ method: 'setup.status' }, { method: 'setup.runtime_check', params: { provider: 'nous' } }])
})
})
diff --git a/apps/desktop/src/lib/runtime-readiness.ts b/apps/desktop/src/lib/runtime-readiness.ts
index 0d6c16b14b3..8473fc82f70 100644
--- a/apps/desktop/src/lib/runtime-readiness.ts
+++ b/apps/desktop/src/lib/runtime-readiness.ts
@@ -69,9 +69,7 @@ export async function fetchRuntimeReadinessSignals(
requestGateway: RuntimeReadinessRequester,
requestedProvider?: string
): Promise {
- const runtimeParams = requestedProvider?.trim()
- ? { provider: requestedProvider.trim() }
- : undefined
+ const runtimeParams = requestedProvider?.trim() ? { provider: requestedProvider.trim() } : undefined
const [setup, runtime] = await Promise.all([
requestWithFallback(requestGateway, 'setup.status'),
diff --git a/apps/desktop/src/lib/session-branch-tree.ts b/apps/desktop/src/lib/session-branch-tree.ts
index 50c7d6eab6f..f99360d1c5e 100644
--- a/apps/desktop/src/lib/session-branch-tree.ts
+++ b/apps/desktop/src/lib/session-branch-tree.ts
@@ -14,9 +14,11 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S
}
const byVisibleId = new Map()
+
for (const session of sessions) {
byVisibleId.set(session.id, session)
const rootId = session._lineage_root_id?.trim()
+
if (rootId) {
byVisibleId.set(rootId, session)
}
@@ -27,11 +29,13 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S
for (const session of sessions) {
const parentId = session.parent_session_id?.trim()
+
if (!parentId) {
continue
}
const parent = byVisibleId.get(parentId)
+
if (!parent || parent.id === session.id) {
continue
}
@@ -50,17 +54,21 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S
// whole parent→branches cluster together instead of stranding the parent at
// its own stale timestamp. Memoized — each subtree is folded at most once.
const groupRecencyMemo = new Map()
+
const groupRecency = (session: SessionInfo): number => {
const cached = groupRecencyMemo.get(session.id)
+
if (cached !== undefined) {
return cached
}
groupRecencyMemo.set(session.id, recency(session)) // cycle guard
+
const max = (childrenByParent.get(session.id) ?? []).reduce(
(acc, child) => Math.max(acc, groupRecency(child)),
recency(session)
)
+
groupRecencyMemo.set(session.id, max)
return max
diff --git a/apps/desktop/src/lib/summarize-command.test.ts b/apps/desktop/src/lib/summarize-command.test.ts
index 570a5d0ec46..6e2c7c8528b 100644
--- a/apps/desktop/src/lib/summarize-command.test.ts
+++ b/apps/desktop/src/lib/summarize-command.test.ts
@@ -12,9 +12,7 @@ describe('summarizeShellCommand', () => {
})
it('keeps flags on the surviving command', () => {
- expect(summarizeShellCommand('cd /x && pnpm run preview --port 4317 2>&1')).toBe(
- 'pnpm run preview --port 4317'
- )
+ expect(summarizeShellCommand('cd /x && pnpm run preview --port 4317 2>&1')).toBe('pnpm run preview --port 4317')
})
it('drops a source/activate prefix', () => {
@@ -61,7 +59,9 @@ describe('summarizeShellCommand', () => {
it('drops a leading echo banner around a single command', () => {
expect(
- summarizeShellCommand('echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3')
+ summarizeShellCommand(
+ 'echo "--- proto pnpm direct ---"; ~/.proto/tools/node/24.11.0/bin/pnpm --version 2>&1 | tail -3'
+ )
).toBe('~/.proto/tools/node/24.11.0/bin/pnpm --version')
})
diff --git a/apps/desktop/src/lib/yolo-session.ts b/apps/desktop/src/lib/yolo-session.ts
index b53463420d9..72f01ec2be3 100644
--- a/apps/desktop/src/lib/yolo-session.ts
+++ b/apps/desktop/src/lib/yolo-session.ts
@@ -32,10 +32,7 @@ export async function setSessionYolo(
* the CLI, the TUI, and cron — and it survives restarts. Triggered by
* Shift+clicking the status-bar zap.
*/
-export async function setGlobalYolo(
- requestGateway: GatewayRequester,
- enabled: boolean
-): Promise {
+export async function setGlobalYolo(requestGateway: GatewayRequester, enabled: boolean): Promise {
const result = await requestGateway<{ value?: string }>('config.set', {
key: 'yolo',
scope: 'global',
diff --git a/apps/desktop/src/store/composer-input-history.test.ts b/apps/desktop/src/store/composer-input-history.test.ts
index 53af5aea442..29322ea3663 100644
--- a/apps/desktop/src/store/composer-input-history.test.ts
+++ b/apps/desktop/src/store/composer-input-history.test.ts
@@ -23,12 +23,7 @@ beforeEach(() => {
describe('deriveUserHistory', () => {
it('returns user messages newest-first with empty/whitespace skipped', () => {
- const messages = [
- MSG('user', ' '),
- MSG('assistant', 'hi'),
- MSG('user', 'first'),
- MSG('user', 'second')
- ]
+ const messages = [MSG('user', ' '), MSG('assistant', 'hi'), MSG('user', 'first'), MSG('user', 'second')]
expect(deriveUserHistory(messages, m => m.text)).toEqual(['second', 'first'])
})
@@ -62,14 +57,10 @@ describe('browseBackward', () => {
// Caller added a new message; ring is now [brand-new, youngest, older].
// Cursor was at 0, next press advances to 1 -> "youngest".
- expect(
- browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
- ).toBe('youngest')
+ expect(browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])).toBe('youngest')
// One more press -> "older".
- expect(
- browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
- ).toBe('older')
+ expect(browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])).toBe('older')
})
})
diff --git a/apps/desktop/src/store/composer-input-history.ts b/apps/desktop/src/store/composer-input-history.ts
index ea727994271..36bd81d2697 100644
--- a/apps/desktop/src/store/composer-input-history.ts
+++ b/apps/desktop/src/store/composer-input-history.ts
@@ -55,11 +55,15 @@ export function deriveUserHistory(
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!
- if (m.role !== 'user') {continue}
+ if (m.role !== 'user') {
+ continue
+ }
const t = getText(m).trim()
- if (t) {out.push(t)}
+ if (t) {
+ out.push(t)
+ }
}
return out
@@ -138,7 +142,9 @@ export function resetBrowseState(sessionId: string | null | undefined) {
const all = { ...$perSessionBrowse.get() }
const existing = all[sessionId]
- if (!existing) {return}
+ if (!existing) {
+ return
+ }
all[sessionId] = { cursor: -1, draftSnapshot: '' }
$perSessionBrowse.set(all)
diff --git a/apps/desktop/src/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts
index a739f2f3cb8..3ac730e5413 100644
--- a/apps/desktop/src/store/composer-popout.ts
+++ b/apps/desktop/src/store/composer-popout.ts
@@ -122,7 +122,10 @@ export function setComposerPoppedOut(value: boolean) {
* unless `persist`. Returns the clamped position so callers can sync their live
* ref. Pass the measured `size` for exact bounds; otherwise a fallback keeps it
* on-screen. */
-export function setComposerPopoutPosition(position: PopoutPosition, { area, persist, size }: SetPositionOptions = {}): PopoutPosition {
+export function setComposerPopoutPosition(
+ position: PopoutPosition,
+ { area, persist, size }: SetPositionOptions = {}
+): PopoutPosition {
const next = clampPosition(position, size, area)
$composerPopoutPosition.set(next)
diff --git a/apps/desktop/src/store/composer-queue.ts b/apps/desktop/src/store/composer-queue.ts
index d2211af0333..922e990fdce 100644
--- a/apps/desktop/src/store/composer-queue.ts
+++ b/apps/desktop/src/store/composer-queue.ts
@@ -216,10 +216,7 @@ export const clearQueuedPrompts = (key: string | null | undefined) => {
* entries enqueued under the old id would otherwise be stranded under a key
* nothing reads anymore. No-op unless both keys resolve and differ.
*/
-export const migrateQueuedPrompts = (
- fromKey: string | null | undefined,
- toKey: string | null | undefined
-): boolean => {
+export const migrateQueuedPrompts = (fromKey: string | null | undefined, toKey: string | null | undefined): boolean => {
const from = sidOf(fromKey)
const to = sidOf(toKey)
diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts
index 08bbb391c95..b57242052db 100644
--- a/apps/desktop/src/store/composer.test.ts
+++ b/apps/desktop/src/store/composer.test.ts
@@ -76,7 +76,10 @@ describe('session drafts', () => {
it('persists draft text (not attachments) to localStorage', () => {
stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })])
- const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record
+ const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record<
+ string,
+ string
+ >
expect(persisted['session-a']).toBe('survives reload')
})
diff --git a/apps/desktop/src/store/keybinds.ts b/apps/desktop/src/store/keybinds.ts
index 7ca8e574d75..05b0732ea02 100644
--- a/apps/desktop/src/store/keybinds.ts
+++ b/apps/desktop/src/store/keybinds.ts
@@ -1,11 +1,6 @@
import { atom, computed } from 'nanostores'
-import {
- defaultBindings,
- KEYBIND_ACTION_IDS,
- keybindAction,
- type KeybindBindings
-} from '@/lib/keybinds/actions'
+import { defaultBindings, KEYBIND_ACTION_IDS, keybindAction, type KeybindBindings } from '@/lib/keybinds/actions'
import { canonicalizeCombo } from '@/lib/keybinds/combo'
import { arraysEqual, persistString, storedString } from '@/lib/storage'
diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts
index 834bbd1101d..b168e35059e 100644
--- a/apps/desktop/src/store/layout.ts
+++ b/apps/desktop/src/store/layout.ts
@@ -67,28 +67,56 @@ export const $sidebarWidth: ReadableAtom = computed($paneStates, states
})
export const $pinnedSessionIds = persistentAtom(SIDEBAR_PINNED_STORAGE_KEY, [] as string[], Codecs.stringArray)
-export const $sidebarSessionOrderIds = persistentAtom(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [] as string[], Codecs.stringArray)
+export const $sidebarSessionOrderIds = persistentAtom(
+ SIDEBAR_SESSION_ORDER_STORAGE_KEY,
+ [] as string[],
+ Codecs.stringArray
+)
export const $sidebarSessionOrderManual = persistentAtom(SIDEBAR_SESSION_ORDER_MANUAL_STORAGE_KEY, false, Codecs.bool)
-export const $sidebarWorkspaceOrderIds = persistentAtom(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [] as string[], Codecs.stringArray)
+export const $sidebarWorkspaceOrderIds = persistentAtom(
+ SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY,
+ [] as string[],
+ Codecs.stringArray
+)
// Order of the top-level repo "parent" groups in the worktree tree (worktrees
// within a parent reuse $sidebarWorkspaceOrderIds).
-export const $sidebarWorkspaceParentOrderIds = persistentAtom(SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY, [] as string[], Codecs.stringArray)
+export const $sidebarWorkspaceParentOrderIds = persistentAtom(
+ SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY,
+ [] as string[],
+ Codecs.stringArray
+)
// Manual drag-order of projects in the overview. Empty = the deterministic
// default sort (active first, explicit before auto, by recency); once the user
// drags a project their order wins (orderByIds surfaces new projects on top).
-export const $sidebarProjectOrderIds = persistentAtom(SIDEBAR_PROJECT_ORDER_STORAGE_KEY, [] as string[], Codecs.stringArray)
+export const $sidebarProjectOrderIds = persistentAtom(
+ SIDEBAR_PROJECT_ORDER_STORAGE_KEY,
+ [] as string[],
+ Codecs.stringArray
+)
// Repo/worktree nodes that the user has explicitly COLLAPSED. Absent = open, so
// a project's folders auto-open when you enter it (and persist your collapses
// across reloads). Keyed by stable node id (repo root / worktree path).
-export const $sidebarWorkspaceCollapsedIds = persistentAtom(SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY, [] as string[], Codecs.stringArray)
+export const $sidebarWorkspaceCollapsedIds = persistentAtom(
+ SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY,
+ [] as string[],
+ Codecs.stringArray
+)
// Auto-derived (git-repo) projects the user has dismissed ("deleted") from the
// overview. Keyed by repo-root path; persisted so they stay hidden. Explicit
// projects are deleted for real instead — this only declutters the auto tier.
-export const $dismissedAutoProjectIds = persistentAtom(SIDEBAR_DISMISSED_AUTO_PROJECTS_STORAGE_KEY, [] as string[], Codecs.stringArray)
+export const $dismissedAutoProjectIds = persistentAtom(
+ SIDEBAR_DISMISSED_AUTO_PROJECTS_STORAGE_KEY,
+ [] as string[],
+ Codecs.stringArray
+)
// Worktree rows removed from the UI after a `git worktree remove`. The on-disk
// dir is gone but historical sessions still reference its path, so we hide the
// row by id (worktree path) to keep "remove" feeling real.
-export const $dismissedWorktreeIds = persistentAtom(SIDEBAR_DISMISSED_WORKTREES_STORAGE_KEY, [] as string[], Codecs.stringArray)
+export const $dismissedWorktreeIds = persistentAtom(
+ SIDEBAR_DISMISSED_WORKTREES_STORAGE_KEY,
+ [] as string[],
+ Codecs.stringArray
+)
export const $sidebarPinsOpen = atom(true)
// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept
// true the whole time it's a floating overlay (not just while shown) so the
@@ -103,7 +131,11 @@ export const $sidebarCronOpen = persistentAtom(SIDEBAR_CRON_OPEN_STORAGE_KEY, fa
// Messaging platform sections collapse by default (they can be numerous and
// tall). We persist the ids the user has *explicitly expanded*, so the default
// stays collapsed unless they've opened a platform before.
-export const $sidebarMessagingOpenIds = persistentAtom(SIDEBAR_MESSAGING_OPEN_STORAGE_KEY, [] as string[], Codecs.stringArray)
+export const $sidebarMessagingOpenIds = persistentAtom(
+ SIDEBAR_MESSAGING_OPEN_STORAGE_KEY,
+ [] as string[],
+ Codecs.stringArray
+)
export const $sidebarAgentsGrouped = persistentAtom(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false, Codecs.bool)
// When true, the sessions sidebar moves to the right and the file browser +
// preview rail move to the left — a mirror of the default layout.
diff --git a/apps/desktop/src/store/model-visibility.test.ts b/apps/desktop/src/store/model-visibility.test.ts
index 805493cd5bc..042e2d4279c 100644
--- a/apps/desktop/src/store/model-visibility.test.ts
+++ b/apps/desktop/src/store/model-visibility.test.ts
@@ -36,9 +36,7 @@ describe('model visibility', () => {
it('does not re-add models from a provider that already has stored choices', () => {
const stored = new Set([modelVisibilityKey('local-ollama', 'qwen3:latest')])
- const visible = effectiveVisibleKeys(stored, [
- provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])
- ])
+ const visible = effectiveVisibleKeys(stored, [provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])])
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
@@ -63,10 +61,7 @@ describe('model visibility', () => {
it('restores model when toggling on after hiding all', () => {
// Simulates: user hid all "nous" models, then toggles one back on.
- const stored = new Set([
- emptyProviderSentinelKey('nous'),
- modelVisibilityKey('ollama', 'qwen3:latest')
- ])
+ const stored = new Set([emptyProviderSentinelKey('nous'), modelVisibilityKey('ollama', 'qwen3:latest')])
// After toggle: sentinel removed, one model added.
const afterToggle = new Set(stored)
diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts
index 44f15b4c32a..a6fd9a40a18 100644
--- a/apps/desktop/src/store/model-visibility.ts
+++ b/apps/desktop/src/store/model-visibility.ts
@@ -23,8 +23,7 @@ export const emptyProviderSentinelKey = (provider: string): string =>
modelVisibilityKey(provider, EMPTY_PROVIDER_SENTINEL)
/** Check whether a stored key is a provider-hidden sentinel. */
-export const isProviderSentinel = (key: string): boolean =>
- key.endsWith('::')
+export const isProviderSentinel = (key: string): boolean => key.endsWith('::')
/** A model and its optional `…-fast` sibling, collapsed into one logical row.
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
@@ -128,10 +127,7 @@ function expandProviderDefaults(provider: ModelOptionProvider, target: Set | null,
- providers: readonly ModelOptionProvider[]
-): Set {
+export function resolveVisibleKeys(stored: Set | null, providers: readonly ModelOptionProvider[]): Set {
if (!stored) {
return defaultVisibleKeys(providers)
}
@@ -145,9 +141,7 @@ export function resolveVisibleKeys(
for (const provider of providers) {
const providerPrefix = `${provider.slug}::`
- const hasStoredProvider = [...stored].some(
- key => key.startsWith(providerPrefix) && !isProviderSentinel(key)
- )
+ const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix) && !isProviderSentinel(key))
const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug))
@@ -199,9 +193,7 @@ export function toggleModelVisibility(
next.delete(key)
// Check if this was the last real model for this provider.
- const remainingForProvider = [...next].some(
- k => k.startsWith(`${providerSlug}::`) && !isProviderSentinel(k)
- )
+ const remainingForProvider = [...next].some(k => k.startsWith(`${providerSlug}::`) && !isProviderSentinel(k))
if (!remainingForProvider) {
next.add(sentinel)
diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts
index 7d5a39bc1ef..17e9964cc81 100644
--- a/apps/desktop/src/store/onboarding.test.ts
+++ b/apps/desktop/src/store/onboarding.test.ts
@@ -1,8 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { OAuthProvider } from '@/types/hermes'
-
import * as notifications from '@/store/notifications'
+import type { OAuthProvider } from '@/types/hermes'
import {
$desktopOnboarding,
@@ -486,7 +485,11 @@ describe('saveOnboardingLocalEndpoint', () => {
// The probe must receive the key so an auth-gated /v1/models enumerates.
const probe = calls.find(c => c.path === '/api/providers/validate')
- expect(probe?.body).toMatchObject({ key: 'OPENAI_BASE_URL', value: 'https://text.example.com/v1', api_key: 'sk-secret' })
+ expect(probe?.body).toMatchObject({
+ key: 'OPENAI_BASE_URL',
+ value: 'https://text.example.com/v1',
+ api_key: 'sk-secret'
+ })
// And the key must be persisted alongside the endpoint for runtime auth.
const assign = calls.find(c => c.path === '/api/model/set')
diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts
index 15c129cc84b..9ef3754be7b 100644
--- a/apps/desktop/src/store/onboarding.ts
+++ b/apps/desktop/src/store/onboarding.ts
@@ -169,8 +169,7 @@ const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e))
const patch = (update: Partial) =>
$desktopOnboarding.set({ ...$desktopOnboarding.get(), ...update })
-const setFlow = (flow: OnboardingFlow) =>
- patch(flow.status === 'idle' ? { flow } : { flow, reason: null })
+const setFlow = (flow: OnboardingFlow) => patch(flow.status === 'idle' ? { flow } : { flow, reason: null })
const sessionIdFor = (flow: OnboardingFlow) => ('start' in flow && flow.start ? flow.start.session_id : undefined)
@@ -181,10 +180,7 @@ function clearPoll() {
}
}
-async function checkRuntime(
- ctx: OnboardingContext,
- requestedProvider?: string
-): Promise {
+async function checkRuntime(ctx: OnboardingContext, requestedProvider?: string): Promise {
return evaluateRuntimeReadiness(ctx.requestGateway, {
defaultReason: DEFAULT_ONBOARDING_REASON,
requestedProvider,
@@ -192,10 +188,7 @@ async function checkRuntime(
})
}
-function shouldPreserveConfiguredOnFallback(
- runtime: RuntimeReadinessResult,
- state: DesktopOnboardingState
-): boolean {
+function shouldPreserveConfiguredOnFallback(runtime: RuntimeReadinessResult, state: DesktopOnboardingState): boolean {
// A fallback result means both runtime probes were non-authoritative
// (transport timeout/disconnect). Keep a previously verified configured
// state instead of forcing the blocking onboarding overlay.
@@ -527,6 +520,7 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
}
const state = $desktopOnboarding.get()
+
if (shouldPreserveConfiguredOnFallback(runtime, state)) {
// Gateway probes timed out but the user was already configured — don't
// downgrade to the blocking onboarding overlay. Surface a non-blocking
@@ -536,7 +530,8 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
id: 'runtime-not-ready',
kind: 'error',
title: 'Runtime not ready',
- message: 'Hermes Desktop could not verify the running backend on startup. Some features may be unavailable until the gateway is reachable.'
+ message:
+ 'Hermes Desktop could not verify the running backend on startup. Some features may be unavailable until the gateway is reachable.'
})
return false
diff --git a/apps/desktop/src/store/panes.ts b/apps/desktop/src/store/panes.ts
index 266544fc039..9a67c2c8fbe 100644
--- a/apps/desktop/src/store/panes.ts
+++ b/apps/desktop/src/store/panes.ts
@@ -25,7 +25,9 @@ function isSnapshot(value: unknown): value is PaneStateSnapshot {
return false
}
- const widthOk = r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
+ const widthOk =
+ r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
+
const heightOk =
r.heightOverride === undefined || (typeof r.heightOverride === 'number' && Number.isFinite(r.heightOverride))
diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts
index 40d629552fe..1be1f2209db 100644
--- a/apps/desktop/src/store/pet-gallery.ts
+++ b/apps/desktop/src/store/pet-gallery.ts
@@ -380,11 +380,14 @@ export function setPetScale(request: GatewayRequest, scale: number): void {
export async function exportPet(request: GatewayRequest, slug: string, fallback: string): Promise {
$petBusy.set(slug)
$petGalleryError.set(null)
+
try {
const res = await petRpc<{ ok: boolean; filename: string; zipBase64: string }>(request, 'pet.export', { slug })
+
if (!res?.ok || !res.zipBase64) {
throw new Error(fallback)
}
+
const bytes = Uint8Array.from(atob(res.zipBase64), c => c.charCodeAt(0))
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }))
const anchor = document.createElement('a')
@@ -392,9 +395,11 @@ export async function exportPet(request: GatewayRequest, slug: string, fallback:
anchor.download = res.filename || `${slug}.zip`
anchor.click()
URL.revokeObjectURL(url)
+
return true
} catch (e) {
$petGalleryError.set(e instanceof Error ? e.message : fallback)
+
return false
} finally {
$petBusy.set(null)
@@ -479,6 +484,7 @@ export function removePet(request: GatewayRequest, slug: string, fallback: strin
if (p.slug !== slug) {
return [p]
}
+
return p.generated || !p.spritesheetUrl ? [] : [{ ...p, installed: false }]
})
}))
diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts
index 9659115fb9a..021a6cef6cd 100644
--- a/apps/desktop/src/store/pet-generate.ts
+++ b/apps/desktop/src/store/pet-generate.ts
@@ -82,15 +82,7 @@ export interface PetDraft {
dataUri: string
}
-export type PetGenStatus =
- | 'idle'
- | 'generating'
- | 'ready'
- | 'hatching'
- | 'preview'
- | 'adopting'
- | 'error'
- | 'stale'
+export type PetGenStatus = 'idle' | 'generating' | 'ready' | 'hatching' | 'preview' | 'adopting' | 'error' | 'stale'
/** Live hatch step for the egg screen — which row is being drawn, then compose/save. */
export interface PetHatchStage {
@@ -410,9 +402,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO
return
}
- $petGenDrafts.set(
- [...current, { index: draft.index, dataUri: draft.dataUri }].sort((a, b) => a.index - b.index)
- )
+ $petGenDrafts.set([...current, { index: draft.index, dataUri: draft.dataUri }].sort((a, b) => a.index - b.index))
}) ?? (() => {})
try {
diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts
index f62ee25745d..68b0c523982 100644
--- a/apps/desktop/src/store/pet.ts
+++ b/apps/desktop/src/store/pet.ts
@@ -114,8 +114,7 @@ export const markPetUnread = () => $petUnread.set(true)
export const clearPetUnread = () => $petUnread.set(false)
/** Steady activity flags (toolRunning / reasoning) set + cleared by the stream. */
-export const setPetActivity = (next: Partial) =>
- $petActivity.set({ ...$petActivity.get(), ...next })
+export const setPetActivity = (next: Partial) => $petActivity.set({ ...$petActivity.get(), ...next })
let flashTimer: ReturnType | undefined
@@ -129,10 +128,7 @@ let flashTimer: ReturnType | undefined
export const flashPetActivity = (next: Partial, ms = 1600) => {
setPetActivity({ celebrate: false, error: false, justCompleted: false, ...next })
clearTimeout(flashTimer)
- flashTimer = setTimeout(
- () => setPetActivity({ celebrate: false, error: false, justCompleted: false }),
- ms
- )
+ flashTimer = setTimeout(() => setPetActivity({ celebrate: false, error: false, justCompleted: false }), ms)
}
export const setPetInfo = (info: PetInfo) => $petInfo.set(info)
@@ -146,21 +142,18 @@ export const setPetInfo = (info: PetInfo) => $petInfo.set(info)
* mirrored to the pop-out overlay through the same atom, so both surfaces agree
* without the overlay needing the session list.
*/
-export const $petState = computed(
- [$petActivity, $busy],
- (activity, busy): PetState => {
- const live = activity.busy ?? busy
+export const $petState = computed([$petActivity, $busy], (activity, busy): PetState => {
+ const live = activity.busy ?? busy
- return derivePetState({
- busy: live,
- awaitingInput: activity.awaitingInput,
- // Steady flags only count mid-turn — ignore stale ones once at rest so an
- // interrupted turn can't pin the pet on `run`/`review`.
- toolRunning: live && activity.toolRunning,
- reasoning: live && activity.reasoning,
- error: activity.error,
- justCompleted: activity.justCompleted,
- celebrate: activity.celebrate
- })
- }
-)
+ return derivePetState({
+ busy: live,
+ awaitingInput: activity.awaitingInput,
+ // Steady flags only count mid-turn — ignore stale ones once at rest so an
+ // interrupted turn can't pin the pet on `run`/`review`.
+ toolRunning: live && activity.toolRunning,
+ reasoning: live && activity.reasoning,
+ error: activity.error,
+ justCompleted: activity.justCompleted,
+ celebrate: activity.celebrate
+ })
+})
diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts
index e90e588f624..d30ed4c1972 100644
--- a/apps/desktop/src/store/projects.ts
+++ b/apps/desktop/src/store/projects.ts
@@ -142,10 +142,7 @@ export function projectIdForCwd(cwd: string): null | string {
// Match project + repo roots AND each worktree-lane path: a linked worktree
// (e.g. a sibling `repo-retry`) lives OUTSIDE the repo root, so root-prefix
// matching alone would miss it — but it's still part of the project.
- const paths = [
- project.path,
- ...project.repos.flatMap(repo => [repo.path, ...repo.groups.map(group => group.path)])
- ]
+ const paths = [project.path, ...project.repos.flatMap(repo => [repo.path, ...repo.groups.map(group => group.path)])]
for (const path of paths) {
const p = (path || '').trim()
diff --git a/apps/desktop/src/store/prompts.test.ts b/apps/desktop/src/store/prompts.test.ts
index d6ddeabf197..57f1985e7a1 100644
--- a/apps/desktop/src/store/prompts.test.ts
+++ b/apps/desktop/src/store/prompts.test.ts
@@ -55,7 +55,12 @@ describe('approval prompt store', () => {
})
it('carries allowPermanent so the bar can hide "Always allow"', () => {
- setApprovalRequest({ allowPermanent: false, command: 'curl x | bash', description: 'content-security', sessionId: 's1' })
+ setApprovalRequest({
+ allowPermanent: false,
+ command: 'curl x | bash',
+ description: 'content-security',
+ sessionId: 's1'
+ })
expect($approvalRequest.get()?.allowPermanent).toBe(false)
})
diff --git a/apps/desktop/src/store/review.ts b/apps/desktop/src/store/review.ts
index 338725e53d7..8aa2a5c02f8 100644
--- a/apps/desktop/src/store/review.ts
+++ b/apps/desktop/src/store/review.ts
@@ -108,6 +108,7 @@ export async function refreshReview(): Promise {
if (!$reviewOpen.get() || !ctx) {
$reviewFiles.set([])
$reviewIsRepo.set(Boolean(ctx))
+
// Critical: clear loading on the no-cwd / not-a-repo path too. It's set
// true (optimistically) before a refresh is scheduled, so skipping it here
// strands the pane on a forever-skeleton for a fresh, detached chat.
diff --git a/apps/desktop/src/store/session-switcher.ts b/apps/desktop/src/store/session-switcher.ts
index 4c8943376e9..ffbcccdace2 100644
--- a/apps/desktop/src/store/session-switcher.ts
+++ b/apps/desktop/src/store/session-switcher.ts
@@ -95,8 +95,7 @@ export function openOrAdvanceSwitcher(direction: 1 | -1): string | null {
return sessions[nextIndex]?.id ?? null
}
-export const highlightedSessionId = (): string | null =>
- $switcherSessions.get()[$switcherIndex.get()]?.id ?? null
+export const highlightedSessionId = (): string | null => $switcherSessions.get()[$switcherIndex.get()]?.id ?? null
export const slotSessionId = (slot: number): string | null =>
($switcherOpen.get() || pendingBrowse ? $switcherSessions.get() : $sessions.get())[slot - 1]?.id ?? null
diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts
index 189187f56a1..013ad0efd48 100644
--- a/apps/desktop/src/store/session.test.ts
+++ b/apps/desktop/src/store/session.test.ts
@@ -150,13 +150,9 @@ describe('mergeSessionPage', () => {
// the sidebar showed both the old tip and the new tip as separate rows.
// The old tip must be evicted because its lineage key matches the incoming
// new tip's lineage key.
- const previous = [
- session({ id: 'tip-4', _lineage_root_id: 'root' }),
- session({ id: 'other' }),
- ] as SessionInfo[]
- const incoming = [
- session({ id: 'tip-5', _lineage_root_id: 'root' }),
- ] as SessionInfo[]
+ const previous = [session({ id: 'tip-4', _lineage_root_id: 'root' }), session({ id: 'other' })] as SessionInfo[]
+
+ const incoming = [session({ id: 'tip-5', _lineage_root_id: 'root' })] as SessionInfo[]
// 'tip-4' is in the keep set (e.g. it was the active/working session),
// but should still be evicted because the incoming page carries the same
@@ -173,12 +169,11 @@ describe('mergeSessionPage', () => {
// from a different lineage that happen to be in the keep set.
const previous = [
session({ id: 'a-old', _lineage_root_id: 'lineage-a' }),
- session({ id: 'b', _lineage_root_id: 'lineage-b' }),
- ] as SessionInfo[]
- const incoming = [
- session({ id: 'a-new', _lineage_root_id: 'lineage-a' }),
+ session({ id: 'b', _lineage_root_id: 'lineage-b' })
] as SessionInfo[]
+ const incoming = [session({ id: 'a-new', _lineage_root_id: 'lineage-a' })] as SessionInfo[]
+
const merged = mergeSessionPage(previous, incoming, ['b'])
expect(merged.map(s => s.id)).toEqual(['b', 'a-new'])
diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts
index c01db643d21..2be40853054 100644
--- a/apps/desktop/src/store/session.ts
+++ b/apps/desktop/src/store/session.ts
@@ -161,10 +161,12 @@ export function mergeSessionPage(
// auto-titler. A real clear sets the local title null first, so this never
// masks one.
const prevById = new Map(previous.map(session => [session.id, session]))
+
const merged = incoming.map(session => {
if (session.title?.trim()) {
return session
}
+
const carried = prevById.get(session.id)?.title?.trim()
return carried ? { ...session, title: carried } : session
@@ -175,13 +177,12 @@ export function mergeSessionPage(
}
const incomingIds = new Set(merged.map(session => session.id))
+
// Deduplicate by compression lineage: when auto-compression rotates the tip
// id (old #4 → new #5), the incoming page carries the new tip but the
// previous list still holds the old one. Without lineage-level dedup both
// rows survive as separate sidebar entries (fixes #43483).
- const incomingLineageKeys = new Set(
- merged.map(session => session._lineage_root_id ?? session.id)
- )
+ const incomingLineageKeys = new Set(merged.map(session => session._lineage_root_id ?? session.id))
const survivors = previous.filter(
session =>
diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts
index 09f89daa0da..122df803f3f 100644
--- a/apps/desktop/src/store/updates.test.ts
+++ b/apps/desktop/src/store/updates.test.ts
@@ -53,6 +53,7 @@ const {
$updateOverlayOpen,
resetUpdateApplyState
} = await import('./updates')
+
const { setConnection } = await import('./session')
const status = (over: Partial = {}): DesktopUpdateStatus => ({
@@ -348,7 +349,15 @@ describe('applyBackendUpdate recovery', () => {
checkHermesUpdateSpy.mockReset()
updateHermesSpy.mockReset()
getActionStatusSpy.mockReset()
- $backendUpdateApply.set({ applying: false, stage: 'idle', message: '', percent: null, error: null, command: null, log: [] })
+ $backendUpdateApply.set({
+ applying: false,
+ stage: 'idle',
+ message: '',
+ percent: null,
+ error: null,
+ command: null,
+ log: []
+ })
vi.useFakeTimers()
})
@@ -359,7 +368,15 @@ describe('applyBackendUpdate recovery', () => {
it('waits for the backend to return after the restart drops the connection, then clears the overlay', async () => {
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
- checkHermesUpdateSpy.mockResolvedValue({ install_method: 'git', current_version: '0.16.0', behind: 0, update_available: false, can_apply: true, update_command: 'hermes update', message: null })
+ checkHermesUpdateSpy.mockResolvedValue({
+ install_method: 'git',
+ current_version: '0.16.0',
+ behind: 0,
+ update_available: false,
+ can_apply: true,
+ update_command: 'hermes update',
+ message: null
+ })
const promise = applyBackendUpdate()
await vi.advanceTimersByTimeAsync(5000)
diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts
index 86cf75b4a9b..97a0fadb14b 100644
--- a/apps/desktop/src/store/updates.ts
+++ b/apps/desktop/src/store/updates.ts
@@ -57,11 +57,13 @@ export type UpdateTarget = 'client' | 'backend'
export const $updateOverlayTarget = atom('client')
export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open)
+
export const openUpdateOverlayFor = (target: UpdateTarget) => {
$updateOverlayTarget.set(target)
$updateOverlayOpen.set(true)
void (target === 'backend' ? checkBackendUpdates() : checkUpdates())
}
+
export const resetUpdateApplyState = () => {
$updateApply.set(IDLE)
$backendUpdateApply.set(IDLE)
@@ -423,6 +425,7 @@ const BACKEND_RETURN_MAX_ATTEMPTS = 40
async function waitForBackendReturn(): Promise {
for (let attempt = 0; attempt < BACKEND_RETURN_MAX_ATTEMPTS; attempt += 1) {
await new Promise(resolve => globalThis.setTimeout(resolve, BACKEND_RETURN_POLL_MS))
+
try {
await checkHermesUpdate()
@@ -457,10 +460,12 @@ function finishBackendApply(returned: boolean): DesktopUpdateApplyResult {
function ingestBackendActionStatus(status: Awaited>): void {
const current = $backendUpdateApply.get()
+
const log = status.lines
.filter(line => line.trim().length > 0)
.map(line => ({ at: Date.now(), message: line, stage: current.stage }))
.slice(-50)
+
const latest = log.at(-1)?.message
if (log.length === 0 && !latest) {
@@ -476,7 +481,12 @@ function ingestBackendActionStatus(status: Awaited {
dismissNotification(UPDATE_TOAST_ID)
- $backendUpdateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: translateNow('updates.applyStatus.preparing') })
+ $backendUpdateApply.set({
+ ...IDLE,
+ applying: true,
+ stage: 'prepare',
+ message: translateNow('updates.applyStatus.preparing')
+ })
try {
const started = await updateHermes()
@@ -489,11 +499,18 @@ export async function applyBackendUpdate(): Promise {
return { ok: false, error: 'manual', manual: true, message, command }
}
- $backendUpdateApply.set({ ...IDLE, applying: true, stage: 'pull', message: translateNow('updates.applyStatus.pulling') })
+ $backendUpdateApply.set({
+ ...IDLE,
+ applying: true,
+ stage: 'pull',
+ message: translateNow('updates.applyStatus.pulling')
+ })
let last: Awaited> | null = null
+
for (let attempt = 0; attempt < 30; attempt += 1) {
await new Promise(resolve => globalThis.setTimeout(resolve, 1500))
+
try {
last = await getActionStatus(started.name, 200)
ingestBackendActionStatus(last)
@@ -515,8 +532,14 @@ export async function applyBackendUpdate(): Promise {
}
const ok = !!last && (last.exit_code ?? 1) === 0
+
if (ok) {
- $backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: true, stage: 'restart', message: translateNow('updates.applyStatus.restarting') })
+ $backendUpdateApply.set({
+ ...$backendUpdateApply.get(),
+ applying: true,
+ stage: 'restart',
+ message: translateNow('updates.applyStatus.restarting')
+ })
return finishBackendApply(await waitForBackendReturn())
}
@@ -532,7 +555,13 @@ export async function applyBackendUpdate(): Promise {
return { ok: false, error: 'apply-failed', message: 'Backend update failed.' }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
- $backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message })
+ $backendUpdateApply.set({
+ ...$backendUpdateApply.get(),
+ applying: false,
+ stage: 'error',
+ error: 'apply-failed',
+ message
+ })
return { ok: false, error: 'apply-failed', message }
}
@@ -541,6 +570,7 @@ export async function applyBackendUpdate(): Promise {
function ingestProgress(payload: DesktopUpdateProgress): void {
const current = $updateApply.get()
const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50)
+
const terminal =
payload.stage === 'error' ||
payload.stage === 'restart' ||
@@ -591,16 +621,21 @@ export function startUpdatePoller(): void {
if (conn?.mode === lastConnectionMode) {
return
}
+
lastConnectionMode = conn?.mode
+
if (conn?.mode === 'remote') {
void checkBackendUpdates()
}
})
window.addEventListener('focus', onFocus)
- backgroundTimer = setInterval(() => {
- void checkBackendUpdates()
- }, 30 * 60 * 1000)
+ backgroundTimer = setInterval(
+ () => {
+ void checkBackendUpdates()
+ },
+ 30 * 60 * 1000
+ )
}
export function stopUpdatePoller(): void {
diff --git a/apps/desktop/src/themes/color.ts b/apps/desktop/src/themes/color.ts
index 8bb4e9ca3aa..799200deb36 100644
--- a/apps/desktop/src/themes/color.ts
+++ b/apps/desktop/src/themes/color.ts
@@ -18,7 +18,13 @@ export function hexToRgb(hex: string): [number, number, number] | null {
}
export const rgbToHex = ([r, g, b]: [number, number, number]): string =>
- `#${[r, g, b].map(n => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')).join('')}`
+ `#${[r, g, b]
+ .map(n =>
+ Math.round(Math.min(255, Math.max(0, n)))
+ .toString(16)
+ .padStart(2, '0')
+ )
+ .join('')}`
export function mix(a: string, b: string, amount: number): string {
const ar = hexToRgb(a)
diff --git a/apps/desktop/src/themes/install.test.ts b/apps/desktop/src/themes/install.test.ts
index 42b777681b3..5231f764695 100644
--- a/apps/desktop/src/themes/install.test.ts
+++ b/apps/desktop/src/themes/install.test.ts
@@ -21,7 +21,10 @@ const ansiColors = (red: string) => ({
})
const themeJsonWithAnsi = (type: 'light' | 'dark', background: string, foreground: string, red: string) =>
- JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground, ...ansiColors(red) } })
+ JSON.stringify({
+ type,
+ colors: { 'editor.background': background, 'editor.foreground': foreground, ...ansiColors(red) }
+ })
describe('buildThemeFromMarketplace', () => {
it('folds a light + dark variant into one family with both slots', () => {
@@ -77,8 +80,16 @@ describe('buildThemeFromMarketplace', () => {
extensionId: 'ryanolsonx.solarized',
displayName: 'Solarized',
themes: [
- { label: 'Solarized Light', uiTheme: 'vs', contents: themeJsonWithAnsi('light', '#fdf6e3', '#586e75', '#dc322f') },
- { label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#002b36', '#93a1a1', '#ff5f56') }
+ {
+ label: 'Solarized Light',
+ uiTheme: 'vs',
+ contents: themeJsonWithAnsi('light', '#fdf6e3', '#586e75', '#dc322f')
+ },
+ {
+ label: 'Solarized Dark',
+ uiTheme: 'vs-dark',
+ contents: themeJsonWithAnsi('dark', '#002b36', '#93a1a1', '#ff5f56')
+ }
]
}
@@ -91,7 +102,9 @@ describe('buildThemeFromMarketplace', () => {
const result: DesktopMarketplaceThemeResult = {
extensionId: 'dracula-theme.theme-dracula',
displayName: 'Dracula',
- themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#282a36', '#f8f8f2', '#ff5555') }]
+ themes: [
+ { label: 'Dracula', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#282a36', '#f8f8f2', '#ff5555') }
+ ]
}
const theme = buildThemeFromMarketplace(result)
@@ -112,8 +125,8 @@ describe('buildThemeFromMarketplace', () => {
})
it('throws when the extension contributes no themes', () => {
- expect(() =>
- buildThemeFromMarketplace({ extensionId: 'x.y', displayName: 'X', themes: [] })
- ).toThrow(/does not contribute/i)
+ expect(() => buildThemeFromMarketplace({ extensionId: 'x.y', displayName: 'X', themes: [] })).toThrow(
+ /does not contribute/i
+ )
})
})
diff --git a/apps/desktop/src/themes/install.ts b/apps/desktop/src/themes/install.ts
index 792552f9af7..0958a92a99e 100644
--- a/apps/desktop/src/themes/install.ts
+++ b/apps/desktop/src/themes/install.ts
@@ -17,10 +17,7 @@ import { convertVscodeColorTheme, parseVscodeTheme, vscodeThemeSlug } from './vs
export const MARKETPLACE_ID_RE = /^[\w-]+\.[\w-]+$/
/** Parse + convert + persist a pasted VS Code theme JSON. */
-export function installVscodeThemeFromText(
- text: string,
- opts?: { label?: string; source?: string }
-): DesktopTheme {
+export function installVscodeThemeFromText(text: string, opts?: { label?: string; source?: string }): DesktopTheme {
const raw = parseVscodeTheme(text)
const { theme } = convertVscodeColorTheme(raw, opts)
diff --git a/apps/desktop/src/themes/presets.ts b/apps/desktop/src/themes/presets.ts
index b1f85a9a7f3..9cfb880c660 100644
--- a/apps/desktop/src/themes/presets.ts
+++ b/apps/desktop/src/themes/presets.ts
@@ -9,8 +9,7 @@ import type { DesktopTheme, DesktopThemeTypography } from './types'
// text/mono fonts carry emoji glyphs, so without this emoji render as tofu
// boxes on platforms whose default text font lacks them (e.g. Linux/#40364).
// Covers macOS, Windows, Linux, plus the `emoji` generic for anything else.
-export const EMOJI_FALLBACK =
- '"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", emoji'
+export const EMOJI_FALLBACK = '"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", emoji'
const SYSTEM_SANS =
'"Segoe WPC", "Segoe UI", -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", system-ui, sans-serif, ' +
diff --git a/apps/desktop/src/themes/profile-theme.test.ts b/apps/desktop/src/themes/profile-theme.test.ts
index 7f2809f71bd..ce4a46dc441 100644
--- a/apps/desktop/src/themes/profile-theme.test.ts
+++ b/apps/desktop/src/themes/profile-theme.test.ts
@@ -10,7 +10,14 @@ interface Pref {
}
const cases = [
- { name: 'skin', pref: skinPref as unknown as Pref, fallback: DEFAULT_SKIN_NAME, a: 'ember', b: 'midnight', junk: 'nope' },
+ {
+ name: 'skin',
+ pref: skinPref as unknown as Pref,
+ fallback: DEFAULT_SKIN_NAME,
+ a: 'ember',
+ b: 'midnight',
+ junk: 'nope'
+ },
{ name: 'mode', pref: modePref as unknown as Pref, fallback: 'light', a: 'dark', b: 'system', junk: 'dusk' }
]
diff --git a/apps/desktop/src/themes/vscode.ts b/apps/desktop/src/themes/vscode.ts
index 67c36983a0e..77340f9ea41 100644
--- a/apps/desktop/src/themes/vscode.ts
+++ b/apps/desktop/src/themes/vscode.ts
@@ -143,7 +143,10 @@ const HEX_RE = /^#[0-9a-f]{3,8}$/i
* palette only when the full base set is present. ANSI slots flatten alpha over
* the editor background; selection keeps its alpha so xterm can blend it.
*/
-function extractTerminalPalette(colors: Record, background: string): DesktopTerminalPalette | undefined {
+function extractTerminalPalette(
+ colors: Record,
+ background: string
+): DesktopTerminalPalette | undefined {
const hex = (key: string): string | undefined =>
normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, background) ?? undefined
@@ -163,7 +166,9 @@ function extractTerminalPalette(colors: Record, background: str
const foreground = hex('terminal.foreground')
const cursor = hex('terminalCursor.foreground') ?? hex('terminalCursor.background')
- const selection = typeof colors['terminal.selectionBackground'] === 'string' ? colors['terminal.selectionBackground'].trim() : ''
+
+ const selection =
+ typeof colors['terminal.selectionBackground'] === 'string' ? colors['terminal.selectionBackground'].trim() : ''
if (foreground) {
palette.foreground = foreground
@@ -207,7 +212,12 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
const derived: string[] = []
// Background first: it's the backdrop every other token flattens alpha over.
- const backgroundHit = pick(colors, ['editor.background', 'editorPane.background', 'editorGroup.background'], '#000000')
+ const backgroundHit = pick(
+ colors,
+ ['editor.background', 'editorPane.background', 'editorGroup.background'],
+ '#000000'
+ )
+
const dark = isDarkType(raw, backgroundHit?.value ?? '#1e1e1e')
const background = backgroundHit?.value ?? (dark ? '#1e1e1e' : '#ffffff')
@@ -254,7 +264,13 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
)
const elevated = take(
- ['editorWidget.background', 'dropdown.background', 'menu.background', 'quickInput.background', 'editorSuggestWidget.background'],
+ [
+ 'editorWidget.background',
+ 'dropdown.background',
+ 'menu.background',
+ 'quickInput.background',
+ 'editorSuggestWidget.background'
+ ],
mix(background, foreground, dark ? 0.08 : 0.05)
)
@@ -263,7 +279,10 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
mix(background, foreground, dark ? 0.04 : 0.025)
)
- const sidebar = take(['sideBar.background', 'activityBar.background'], mix(background, foreground, dark ? 0.02 : 0.012))
+ const sidebar = take(
+ ['sideBar.background', 'activityBar.background'],
+ mix(background, foreground, dark ? 0.02 : 0.012)
+ )
// The accent labels the sidebar (--theme-primary), so guarantee it reads
// there — otherwise low-contrast brand colors leave invisible section headers.
@@ -274,7 +293,10 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
mix(background, foreground, dark ? 0.16 : 0.14)
)
- const input = take(['input.background', 'dropdown.background', 'quickInput.background'], mix(background, foreground, dark ? 0.1 : 0.06))
+ const input = take(
+ ['input.background', 'dropdown.background', 'quickInput.background'],
+ mix(background, foreground, dark ? 0.1 : 0.06)
+ )
const mutedForeground = take(
['descriptionForeground', 'editorLineNumber.foreground', 'tab.inactiveForeground', 'disabledForeground'],
@@ -282,7 +304,12 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
)
const destructive = take(
- ['editorError.foreground', 'errorForeground', 'editorOverviewRuler.errorForeground', 'notificationsErrorIcon.foreground'],
+ [
+ 'editorError.foreground',
+ 'errorForeground',
+ 'editorOverviewRuler.errorForeground',
+ 'notificationsErrorIcon.foreground'
+ ],
'#e25563'
)
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index 68289dff86e..08e29ce4b40 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -774,14 +774,17 @@ export interface MoaModelSlot {
export interface MoaConfigResponse {
default_preset: string
active_preset: string
- presets: Record
+ presets: Record<
+ string,
+ {
+ aggregator: MoaModelSlot
+ aggregator_temperature: number
+ enabled: boolean
+ max_tokens: number
+ reference_models: MoaModelSlot[]
+ reference_temperature: number
+ }
+ >
aggregator: MoaModelSlot
aggregator_temperature: number
enabled: boolean
diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts
index 14fc27dfc95..2a8ccef6297 100644
--- a/ui-tui/packages/hermes-ink/index.d.ts
+++ b/ui-tui/packages/hermes-ink/index.d.ts
@@ -7,7 +7,6 @@ export { Ansi } from './src/ink/Ansi.tsx'
export { evictInkCaches } from './src/ink/cache-eviction.ts'
export type { EvictLevel, InkCacheSizes } from './src/ink/cache-eviction.ts'
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
-export type { MouseTrackingMode } from './src/ink/termio/dec.ts'
export { default as Box } from './src/ink/components/Box.tsx'
export type { Props as BoxProps } from './src/ink/components/Box.tsx'
export { default as Link } from './src/ink/components/Link.tsx'
@@ -35,6 +34,7 @@ export { default as measureElement } from './src/ink/measure-element.ts'
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
export { stringWidth } from './src/ink/stringWidth.ts'
+export type { MouseTrackingMode } from './src/ink/termio/dec.ts'
export { wrapAnsi } from './src/ink/wrapAnsi.ts'
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
export type { Props as TextInputProps } from 'ink-text-input'
diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts
index c279a892391..2251fa6c82c 100644
--- a/ui-tui/packages/hermes-ink/src/entry-exports.ts
+++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts
@@ -26,7 +26,7 @@ export { default as measureElement } from './ink/measure-element.js'
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js'
export { stringWidth } from './ink/stringWidth.js'
-export { wrapAnsi } from './ink/wrapAnsi.js'
export { isXtermJs } from './ink/terminal.js'
export type { MouseTrackingMode } from './ink/termio/dec.js'
+export { wrapAnsi } from './ink/wrapAnsi.js'
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
diff --git a/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts b/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts
index 2c5080162ba..f1934716c5f 100644
--- a/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts
@@ -1,4 +1,5 @@
import { EventEmitter } from 'events'
+
import React, { useContext, useEffect } from 'react'
import { describe, expect, it } from 'vitest'
@@ -24,11 +25,13 @@ class FakeTty extends EventEmitter {
}
setRawMode(mode: boolean): this {
this.isRaw = mode
+
return this
}
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
cb?.()
+
return true
}
}
@@ -60,6 +63,7 @@ describe('App raw-mode teardown', () => {
const stdout = new FakeTty()
const stdin = new FakeTty()
const stderr = new FakeTty()
+
const ink = new Ink({
exitOnCtrlC: false,
patchConsole: false,
diff --git a/ui-tui/packages/hermes-ink/src/ink/colorize.test.ts b/ui-tui/packages/hermes-ink/src/ink/colorize.test.ts
index 814b8d91e56..c8d9647dc5d 100644
--- a/ui-tui/packages/hermes-ink/src/ink/colorize.test.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/colorize.test.ts
@@ -57,4 +57,3 @@ describe('richEightBitColorNumber', () => {
expect(richEightBitColorNumber(0xff, 0xf8, 0xdc)).toBe(230)
})
})
-
diff --git a/ui-tui/packages/hermes-ink/src/ink/colorize.ts b/ui-tui/packages/hermes-ink/src/ink/colorize.ts
index 7a8a57a5682..ca361ae2cc9 100644
--- a/ui-tui/packages/hermes-ink/src/ink/colorize.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/colorize.ts
@@ -36,7 +36,13 @@ export function shouldUseRichEightBitDowngradeForLegacyAppleTerminal(
const truecolorOverride = /^(?:1|true|yes|on)$/i.test((env.HERMES_TUI_TRUECOLOR ?? '').trim())
const advertisesTruecolor = /^(?:truecolor|24bit)$/i.test((env.COLORTERM ?? '').trim())
- return termProgram === 'Apple_Terminal' && !truecolorOverride && !advertisesTruecolor && !('FORCE_COLOR' in env) && level === 2
+ return (
+ termProgram === 'Apple_Terminal' &&
+ !truecolorOverride &&
+ !advertisesTruecolor &&
+ !('FORCE_COLOR' in env) &&
+ level === 2
+ )
}
export function richEightBitColorNumber(red: number, green: number, blue: number): number {
diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx
index f05487437bb..6aea5e96998 100644
--- a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx
+++ b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx
@@ -73,14 +73,7 @@ export function AlternateScreen(t0: Props) {
// 1003 hover events asserted, picking 'wheel' or 'buttons' without
// an unconditional DISABLE would silently leave hover on and defeat
// the point of the preset.
- writeRaw(
- ENTER_ALT_SCREEN +
- ERASE_SCROLLBACK +
- ERASE_SCREEN +
- CURSOR_HOME +
- DISABLE_MOUSE_TRACKING +
- enableMouse
- )
+ writeRaw(ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + DISABLE_MOUSE_TRACKING + enableMouse)
ink?.setAltScreenActive(true, mouseTracking)
// setAltScreenActive(true, mouseTracking) above stores the mode for
// SIGCONT/resize/stdin-gap re-assertion. We don't also call
diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts b/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts
index 50628d5380d..c94f6349d8f 100644
--- a/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.test.ts
@@ -32,7 +32,11 @@ describe('dimColorFallback', () => {
})
it('does not apply when dim is explicitly configured', () => {
- expect(dimColorFallback({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBeUndefined()
- expect(dimColorFallback({ HERMES_TUI_DIM: '0', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBeUndefined()
+ expect(
+ dimColorFallback({ HERMES_TUI_DIM: '1', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)
+ ).toBeUndefined()
+ expect(
+ dimColorFallback({ HERMES_TUI_DIM: '0', TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)
+ ).toBeUndefined()
})
})
diff --git a/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts b/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts
index 31039491f89..e4e109c7221 100644
--- a/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/ink-resize.test.ts
@@ -1,4 +1,5 @@
import { EventEmitter } from 'events'
+
import React from 'react'
import { describe, expect, it } from 'vitest'
@@ -15,6 +16,7 @@ class FakeTty extends EventEmitter {
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
cb?.()
+
return true
}
}
@@ -26,6 +28,7 @@ describe('Ink resize healing', () => {
const stdout = new FakeTty()
const stdin = new FakeTty()
const stderr = new FakeTty()
+
const ink = new Ink({
exitOnCtrlC: false,
patchConsole: false,
diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts
index 5fee72cccaf..fdd21c143f7 100644
--- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts
@@ -717,7 +717,10 @@ function renderNodeToOutput(
const childYoga = (child as DOMElement).yogaNode
if (childYoga) {
- scrollHeight = Math.max(scrollHeight, Math.ceil(childYoga.getComputedTop() + childYoga.getComputedHeight()))
+ scrollHeight = Math.max(
+ scrollHeight,
+ Math.ceil(childYoga.getComputedTop() + childYoga.getComputedHeight())
+ )
}
}
}
diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts
index c3322bcfaa6..bed407ed1a4 100644
--- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts
@@ -313,8 +313,10 @@ function linuxCopyArgs(tool: 'wl-copy' | 'xclip' | 'xsel'): string[] {
switch (tool) {
case 'wl-copy':
return []
+
case 'xclip':
return ['-selection', 'clipboard']
+
case 'xsel':
return ['--clipboard', '--input']
}
diff --git a/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts b/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts
index 74c06c0fb77..a682f4d8b96 100644
--- a/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts
+++ b/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.test.ts
@@ -28,6 +28,7 @@ let sleeperPids: number[]
function trackSleeperPid(pidFile: string): void {
try {
const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10)
+
if (pid > 0) {
sleeperPids.push(pid)
}
@@ -59,6 +60,7 @@ afterEach(() => {
// Already exited — fine.
}
}
+
rmSync(scriptDir, { recursive: true, force: true })
})
@@ -70,7 +72,7 @@ describe.skipIf(onWindows)('execFileNoThrow with daemon-style children', () => {
// verify by hand: remove `it.skip` and watch the test timeout. This
// test is here so a reviewer reading the resolveOnExit option knows
// *why* every clipboard-tool spawn in osc.ts wires it on.
- it.skip("(documented hang) without resolveOnExit, await never resolves when daemon inherits stdio", async () => {
+ it.skip('(documented hang) without resolveOnExit, await never resolves when daemon inherits stdio', async () => {
const pidFile = join(scriptDir, 'sleeper-skip.pid')
const result = await execFileNoThrow(daemonScript, [pidFile], { timeout: 300 })
trackSleeperPid(pidFile)
@@ -86,6 +88,7 @@ describe.skipIf(onWindows)('execFileNoThrow with daemon-style children', () => {
timeout: 2000,
resolveOnExit: true
})
+
trackSleeperPid(pidFile)
const elapsed = Date.now() - start
@@ -107,6 +110,7 @@ describe.skipIf(onWindows)('execFileNoThrow with daemon-style children', () => {
timeout: 2000,
resolveOnExit: true
})
+
trackSleeperPid(pidFile)
expect(result.code).toBe(7)
@@ -130,12 +134,14 @@ describe.skipIf(onWindows)('execFileNoThrow with daemon-style children', () => {
it('does not double-resolve when both timer and exit fire', async () => {
const pidFile = join(scriptDir, 'sleeper-race.pid')
+
// Race: child happens to exit right around the timeout. The settled
// guard ensures only the first resolution wins.
const result = await execFileNoThrow(daemonScript, [pidFile], {
timeout: 50, // very tight
resolveOnExit: true
})
+
trackSleeperPid(pidFile)
// Either code=0 (exit beat timer) or code=124 (timer beat exit).
diff --git a/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts b/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts
index a4e32ed14b3..74f12441326 100644
--- a/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts
+++ b/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts
@@ -1,4 +1,4 @@
-import { spawn, type ChildProcess, type StdioOptions } from 'child_process'
+import { type ChildProcess, spawn, type StdioOptions } from 'child_process'
type ExecFileOptions = {
input?: string
timeout?: number
@@ -32,9 +32,7 @@ export function execFileNoThrow(
// doesn't inherit those pipe FDs — prevents handle leaks that can
// keep the parent process alive. No output data is collected in
// this mode; both stdout and stderr will be empty strings.
- const stdioConfig: StdioOptions = options.resolveOnExit
- ? ['pipe', 'ignore', 'ignore']
- : 'pipe'
+ const stdioConfig: StdioOptions = options.resolveOnExit ? ['pipe', 'ignore', 'ignore'] : 'pipe'
const child: ChildProcess = spawn(file, args, {
cwd: options.useCwd ? process.cwd() : undefined,
diff --git a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts
index 53426b0e20c..bf409e95b35 100644
--- a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts
+++ b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts
@@ -102,7 +102,13 @@ describe('session orchestrator helpers', () => {
expect(currentSessionSelectionIndex(sessions, 'second')).toBe(1)
expect(
- currentSessionSelectionIndex([{ id: 'first', status: 'idle' }, { id: 'third', status: 'idle' }], 'third')
+ currentSessionSelectionIndex(
+ [
+ { id: 'first', status: 'idle' },
+ { id: 'third', status: 'idle' }
+ ],
+ 'third'
+ )
).toBe(1)
expect(currentSessionSelectionIndex(sessions, 'missing')).toBe(1)
expect(currentSessionSelectionIndex([], 'missing')).toBe(0)
diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx
index c428c9dc55f..7d5f93a51d0 100644
--- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx
+++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx
@@ -182,6 +182,7 @@ describe('StatusRule background-subagent indicator', () => {
describe('StatusRule session count click target', () => {
it('makes the live session count itself clickable', () => {
const openSwitcher = vi.fn()
+
const element = StatusRule({
bgCount: 0,
busy: false,
@@ -220,7 +221,15 @@ describe('StatusRule session count click target', () => {
statusColor: DEFAULT_THEME.color.ok,
t: DEFAULT_THEME,
turnStartedAt: null,
- usage: { calls: 0, context_max: 200_000, context_percent: 25, context_used: 50_000, input: 0, output: 0, total: 50_000 },
+ usage: {
+ calls: 0,
+ context_max: 200_000,
+ context_percent: 25,
+ context_used: 50_000,
+ input: 0,
+ output: 0,
+ total: 50_000
+ },
voiceLabel: 'voice off'
})
@@ -272,6 +281,7 @@ describe('StatusRule credits notice render priority', () => {
...baseProps,
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' }
})
+
const errText = findElementWithText(errEl, '✕ exhausted')
expect(errText?.props.color).toBe(DEFAULT_THEME.color.error)
@@ -279,6 +289,7 @@ describe('StatusRule credits notice render priority', () => {
...baseProps,
notice: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 }
})
+
const okText = findElementWithText(okEl, '✓ restored')
expect(okText?.props.color).toBe(DEFAULT_THEME.color.statusGood)
})
@@ -288,6 +299,7 @@ describe('StatusRule credits notice render priority', () => {
...baseProps,
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }
})
+
const noticeText = findElementWithText(element, '90% used')
// The leaf carries exactly the policy text — no extra prepended glyph.
@@ -296,6 +308,7 @@ describe('StatusRule credits notice render priority', () => {
it('the notice text is the shrinkable element (flexShrink=1 + truncate-end) so a long notice ellipsizes', () => {
const longText = '⚠ ' + 'x'.repeat(200)
+
const element = StatusRule({
...baseProps,
cols: 50,
@@ -312,18 +325,26 @@ describe('StatusRule credits notice render priority', () => {
if (Array.isArray(node)) {
for (const c of node) {
const f = findShrinkBoxContaining(c)
- if (f) return f
+
+ if (f) {
+ return f
+ }
}
}
+
return null
}
+
if (node.props.flexShrink === 1 && textContent(node).includes('xxxxx') && node.type !== StatusRule) {
// Prefer the closest shrink box that wraps the notice text.
const deeper = findShrinkBoxContaining(node.props.children)
+
return deeper ?? node
}
+
return findShrinkBoxContaining(node.props.children)
}
+
const shrinkBox = findShrinkBoxContaining(element)
expect(shrinkBox).not.toBeNull()
@@ -366,6 +387,7 @@ describe('StatusRule idle-since read-out', () => {
it('shows time since the last final agent response when idle', () => {
const endedAt = Date.now() - 42_000
+
const element = StatusRule({
...baseProps,
lastTurnEndedAt: endedAt,
diff --git a/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx b/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx
index 7d04cfe2758..edf1859b2fd 100644
--- a/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx
+++ b/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx
@@ -2,6 +2,7 @@ import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { StatusRule } from '../components/appChrome.js'
+import type * as EnvModule from '../config/env.js'
import { DEFAULT_THEME } from '../theme.js'
// DEV_CREDITS_MODE is a module-load-time constant (config/env.ts reads
@@ -10,8 +11,9 @@ import { DEFAULT_THEME } from '../theme.js'
// the dev-on value for this file. vitest hoists vi.mock above the imports, so
// appChrome picks up the mocked flag. Lives in its own file so the override
// stays scoped (the other StatusRule tests run with the real, dev-off value).
-vi.mock('../config/env.js', async (importOriginal) => {
- const actual = await importOriginal()
+vi.mock('../config/env.js', async importOriginal => {
+ const actual = await importOriginal()
+
return { ...actual, DEV_CREDITS_MODE: true }
})
diff --git a/ui-tui/src/__tests__/blockLayout.test.ts b/ui-tui/src/__tests__/blockLayout.test.ts
index 525254cebe8..1bd98f7ffb8 100644
--- a/ui-tui/src/__tests__/blockLayout.test.ts
+++ b/ui-tui/src/__tests__/blockLayout.test.ts
@@ -102,6 +102,7 @@ describe('prevRenderedMsg', () => {
{ role: 'system', kind: 'trail', text: '', tools: ['Edit bar.ts'] }, // 3
{ role: 'assistant', text: 'second' } // 4
]
+
const at = (i: number) => rows[i]
it('returns the literal predecessor when everything renders', () => {
diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts
index 93feb009d87..2becc278e05 100644
--- a/ui-tui/src/__tests__/clipboard.test.ts
+++ b/ui-tui/src/__tests__/clipboard.test.ts
@@ -206,21 +206,11 @@ describe('writeClipboardText', () => {
const start = vi.fn().mockReturnValue(child)
- await expect(
- writeClipboardText('x11 text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
- ).resolves.toBe(true)
- expect(start).toHaveBeenNthCalledWith(
- 1,
- 'wl-copy',
- ['--type', 'text/plain'],
- expect.anything()
- )
- expect(start).toHaveBeenNthCalledWith(
- 2,
- 'xclip',
- ['-selection', 'clipboard', '-in'],
- expect.anything()
+ await expect(writeClipboardText('x11 text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })).resolves.toBe(
+ true
)
+ expect(start).toHaveBeenNthCalledWith(1, 'wl-copy', ['--type', 'text/plain'], expect.anything())
+ expect(start).toHaveBeenNthCalledWith(2, 'xclip', ['-selection', 'clipboard', '-in'], expect.anything())
})
it('falls back to xsel when both wl-copy and xclip fail', async () => {
@@ -263,7 +253,9 @@ describe('writeClipboardText', () => {
const start = vi.fn().mockReturnValue(child)
- await expect(writeClipboardText('wsl text', 'linux', start as any, { WSL_DISTRO_NAME: 'Ubuntu' })).resolves.toBe(true)
+ await expect(writeClipboardText('wsl text', 'linux', start as any, { WSL_DISTRO_NAME: 'Ubuntu' })).resolves.toBe(
+ true
+ )
expect(start).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
index 7e6c7a891ae..f6162e47bd5 100644
--- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
+++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
@@ -190,9 +190,7 @@ describe('createGatewayEventHandler', () => {
type: 'review.summary'
} as any)
- expect(ctx.system.sys).toHaveBeenCalledWith(
- "💾 Self-improvement review: Skill 'hermes-release' patched"
- )
+ expect(ctx.system.sys).toHaveBeenCalledWith("💾 Self-improvement review: Skill 'hermes-release' patched")
})
it('ignores review.summary events with empty or missing text', () => {
@@ -879,7 +877,10 @@ describe('createGatewayEventHandler', () => {
it('defaults approval overlays to allowPermanent when the backend omits the field', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
- onEvent({ payload: { command: 'rm -rf /tmp/x', description: 'dangerous command' }, type: 'approval.request' } as any)
+ onEvent({
+ payload: { command: 'rm -rf /tmp/x', description: 'dangerous command' },
+ type: 'approval.request'
+ } as any)
expect(getOverlayState().approval).toMatchObject({ allowPermanent: true })
})
@@ -1188,9 +1189,9 @@ describe('createGatewayEventHandler', () => {
// Settle flips busy false (the single drain edge) and the backend
// "Operation interrupted…" line is suppressed (not appended).
expect(getUiState().busy).toBe(false)
- expect(appended.slice(before).some(m => typeof m.text === 'string' && m.text.includes('Operation interrupted'))).toBe(
- false
- )
+ expect(
+ appended.slice(before).some(m => typeof m.text === 'string' && m.text.includes('Operation interrupted'))
+ ).toBe(false)
})
it('persists an abandoned (timed-out) clarify into the transcript when the clarify tool completes', () => {
diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts
index 9fbe6506d9e..c0487fad053 100644
--- a/ui-tui/src/__tests__/createSlashHandler.test.ts
+++ b/ui-tui/src/__tests__/createSlashHandler.test.ts
@@ -4,6 +4,7 @@ import { createSlashHandler } from '../app/createSlashHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { DASHBOARD_EXIT_DISABLED_MESSAGE, DASHBOARD_UPDATE_DISABLED_MESSAGE } from '../app/slash/commands/core.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
+import type * as EnvModule from '../config/env.js'
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
// DASHBOARD_TUI_MODE resolves once at module load from HERMES_TUI_DASHBOARD,
@@ -11,7 +12,7 @@ import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
// export (everything else stays real) and flip the holder per test.
const envState = { dashboardTuiMode: false }
vi.mock('../config/env.js', async importActual => {
- const actual = await importActual()
+ const actual = await importActual()
return {
...actual,
@@ -218,6 +219,7 @@ describe('createSlashHandler', () => {
it('applies /reasoning hide to the thinking section immediately', async () => {
patchUiState({ sections: { thinking: 'expanded' }, showReasoning: true, sid: 'sid-abc' })
+
const ctx = buildCtx({
gateway: {
...buildGateway(),
@@ -240,6 +242,7 @@ describe('createSlashHandler', () => {
it('applies /reasoning show to the thinking section immediately', async () => {
patchUiState({ sections: { thinking: 'hidden' }, showReasoning: false, sid: 'sid-abc' })
+
const ctx = buildCtx({
gateway: {
...buildGateway(),
@@ -285,10 +288,7 @@ describe('createSlashHandler', () => {
resetOverlayState()
expect(createSlashHandler(ctx)('/pet')).toBe(true)
expect(getOverlayState().petPicker).toBe(false)
- expect(ctx.gateway.gw.request).toHaveBeenCalledWith(
- 'slash.exec',
- expect.objectContaining({ command: 'pet' })
- )
+ expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'pet' }))
resetOverlayState()
expect(createSlashHandler(ctx)('/pet toggle')).toBe(true)
@@ -304,10 +304,7 @@ describe('createSlashHandler', () => {
expect(createSlashHandler(ctx)('/pet boba')).toBe(true)
expect(getOverlayState().petPicker).toBe(false)
- expect(ctx.gateway.gw.request).toHaveBeenCalledWith(
- 'slash.exec',
- expect.objectContaining({ command: 'pet boba' })
- )
+ expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'pet boba' }))
})
it('routes /skills inspect to skills.manage', () => {
@@ -381,11 +378,14 @@ describe('createSlashHandler', () => {
if (method === 'skills.reload') {
return Promise.resolve({ output: '42 skill(s) available' })
}
+
if (method === 'commands.catalog') {
return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
}
+
return Promise.resolve({})
})
+
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/reload-skills')
@@ -551,7 +551,9 @@ describe('createSlashHandler', () => {
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/browser connect')).toBe(true)
- expect(ctx.transcript.sys).toHaveBeenCalledWith('checking Chromium-family browser remote debugging at http://127.0.0.1:9222...')
+ expect(ctx.transcript.sys).toHaveBeenCalledWith(
+ 'checking Chromium-family browser remote debugging at http://127.0.0.1:9222...'
+ )
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith(
diff --git a/ui-tui/src/__tests__/creditsCommand.test.ts b/ui-tui/src/__tests__/creditsCommand.test.ts
index 6f0f6d59eec..b78f9205e15 100644
--- a/ui-tui/src/__tests__/creditsCommand.test.ts
+++ b/ui-tui/src/__tests__/creditsCommand.test.ts
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { creditsCommands } from '../app/slash/commands/credits.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
+import { creditsCommands } from '../app/slash/commands/credits.js'
import type { CreditsViewResponse } from '../gatewayTypes.js'
// The command opens the top-up URL through this helper on confirm. Mock it so
@@ -30,7 +30,7 @@ const buildView = (overrides: Partial = {}): CreditsViewRes
// command is stale OR the response is falsy. Tests stay non-stale, so this is a
// straightforward "run the handler when we got a response" shim.
const guarded =
- (fn: (r: T) => void) =>
+ (fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
@@ -54,7 +54,6 @@ const buildCtx = (rpcResult: CreditsViewResponse) => {
// Run the command, then await the rpc promise so the .then() handler has
// flushed before assertions — deterministic, no polling/timeouts.
const run = async () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
creditsCommand.run('', ctx as any, 'credits')
await rpc.mock.results[0]?.value
// Allow the chained .then() microtask to settle.
@@ -97,9 +96,7 @@ describe('/credits slash command', () => {
// onConfirm opens the URL and reports success back to the transcript
confirm?.onConfirm()
expect(openExternalUrlMock).toHaveBeenCalledWith(view.topup_url)
- expect(sys).toHaveBeenCalledWith(
- 'Complete your top-up in the browser — credits will appear in /credits shortly.'
- )
+ expect(sys).toHaveBeenCalledWith('Complete your top-up in the browser — credits will appear in /credits shortly.')
})
it('falls back to printing the URL when the browser open is rejected', async () => {
@@ -133,6 +130,7 @@ describe('/credits slash command', () => {
logged_in: false,
topup_url: null
})
+
const { run, sys } = buildCtx(view)
await run()
diff --git a/ui-tui/src/__tests__/externalLink.test.ts b/ui-tui/src/__tests__/externalLink.test.ts
index 5bd9757c2c0..5a3673f8314 100644
--- a/ui-tui/src/__tests__/externalLink.test.ts
+++ b/ui-tui/src/__tests__/externalLink.test.ts
@@ -26,7 +26,9 @@ describe('external link helpers', () => {
it('derives readable title fallbacks from URL slugs', () => {
expect(
- urlSlugTitleLabel('https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/')
+ urlSlugTitleLabel(
+ 'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/'
+ )
).toBe('From Fajardo Icacos Island Full Day Catamaran Trip')
})
@@ -71,7 +73,9 @@ describe('external link helpers', () => {
vi.stubGlobal('fetch', fetchMock)
- const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure.a46272756.activity-details'
+ const url =
+ 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure.a46272756.activity-details'
+
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
@@ -114,7 +118,8 @@ describe('external link helpers', () => {
vi.stubGlobal('fetch', fetchMock)
- const url = 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
+ const url =
+ 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
await expect(fetchLinkTitle(url)).resolves.toBe('')
})
diff --git a/ui-tui/src/__tests__/mathUnicode.test.ts b/ui-tui/src/__tests__/mathUnicode.test.ts
index fb9f029aa8b..e7abb3efea5 100644
--- a/ui-tui/src/__tests__/mathUnicode.test.ts
+++ b/ui-tui/src/__tests__/mathUnicode.test.ts
@@ -45,7 +45,9 @@ describe('texToUnicode — symbols', () => {
describe('texToUnicode — blackboard / calligraphic / fraktur', () => {
it('renders \\mathbb capitals', () => {
expect(texToUnicode('\\mathbb{R}')).toBe('ℝ')
- expect(texToUnicode('\\mathbb{N} \\subset \\mathbb{Z} \\subset \\mathbb{Q} \\subset \\mathbb{R}')).toBe('ℕ ⊂ ℤ ⊂ ℚ ⊂ ℝ')
+ expect(texToUnicode('\\mathbb{N} \\subset \\mathbb{Z} \\subset \\mathbb{Q} \\subset \\mathbb{R}')).toBe(
+ 'ℕ ⊂ ℤ ⊂ ℚ ⊂ ℝ'
+ )
})
it('renders \\mathcal and \\mathfrak', () => {
@@ -119,7 +121,7 @@ describe('texToUnicode — fractions', () => {
expect(texToUnicode('\\frac{1}{\\frac{1}{x}}')).toBe('1/(1/x)')
})
- it('handles braces inside numerator / denominator (regression: regex \\frac couldn\'t)', () => {
+ it("handles braces inside numerator / denominator (regression: regex \\frac couldn't)", () => {
// The regex-only `\frac` matcher used `[^{}]*` for each arg, which
// failed the moment a numerator contained its own braces (here the
// `{p-1}` from a superscript). The balanced-brace parser handles it.
@@ -198,7 +200,7 @@ describe('texToUnicode — \\boxed / \\fbox', () => {
expect(stripBox(texToUnicode('\\fbox{answer}'))).toBe('answer')
})
- it('handles boxed expressions with nested braces (regression: regex couldn\'t)', () => {
+ it("handles boxed expressions with nested braces (regression: regex couldn't)", () => {
// A `[^{}]*` regex would stop at the first `{` inside the body. The
// balanced-brace parser walks past it.
expect(stripBox(texToUnicode('\\boxed{x^{n+1}}'))).toBe('xⁿ⁺¹')
diff --git a/ui-tui/src/__tests__/memoryMonitor.test.ts b/ui-tui/src/__tests__/memoryMonitor.test.ts
index 0a8d853398f..0983847593d 100644
--- a/ui-tui/src/__tests__/memoryMonitor.test.ts
+++ b/ui-tui/src/__tests__/memoryMonitor.test.ts
@@ -71,7 +71,13 @@ describe('startMemoryMonitor thresholds (#34095)', () => {
await vi.advanceTimersByTimeAsync(2) // seed lastHeap at 100MB, below floor
expect(onWarn).not.toHaveBeenCalled()
- spy.mockReturnValue({ arrayBuffers: 0, external: 0, heapTotal: 800 * MB, heapUsed: 800 * MB, rss: 800 * MB } as NodeJS.MemoryUsage)
+ spy.mockReturnValue({
+ arrayBuffers: 0,
+ external: 0,
+ heapTotal: 800 * MB,
+ heapUsed: 800 * MB,
+ rss: 800 * MB
+ } as NodeJS.MemoryUsage)
await vi.advanceTimersByTimeAsync(2) // jumped 700MB → above floor + steep
expect(onWarn).toHaveBeenCalledTimes(1)
@@ -80,9 +86,21 @@ describe('startMemoryMonitor thresholds (#34095)', () => {
expect(onWarn).toHaveBeenCalledTimes(1)
// Falls back below the floor → re-armed, then climbs again → fires again.
- spy.mockReturnValue({ arrayBuffers: 0, external: 0, heapTotal: 100 * MB, heapUsed: 100 * MB, rss: 100 * MB } as NodeJS.MemoryUsage)
+ spy.mockReturnValue({
+ arrayBuffers: 0,
+ external: 0,
+ heapTotal: 100 * MB,
+ heapUsed: 100 * MB,
+ rss: 100 * MB
+ } as NodeJS.MemoryUsage)
await vi.advanceTimersByTimeAsync(2)
- spy.mockReturnValue({ arrayBuffers: 0, external: 0, heapTotal: 800 * MB, heapUsed: 800 * MB, rss: 800 * MB } as NodeJS.MemoryUsage)
+ spy.mockReturnValue({
+ arrayBuffers: 0,
+ external: 0,
+ heapTotal: 800 * MB,
+ heapUsed: 800 * MB,
+ rss: 800 * MB
+ } as NodeJS.MemoryUsage)
await vi.advanceTimersByTimeAsync(2)
expect(onWarn).toHaveBeenCalledTimes(2)
})
@@ -94,7 +112,13 @@ describe('startMemoryMonitor thresholds (#34095)', () => {
await vi.advanceTimersByTimeAsync(2)
// +50MB per tick — above the floor but gentle, not a render-tree blowup.
- spy.mockReturnValue({ arrayBuffers: 0, external: 0, heapTotal: 700 * MB, heapUsed: 700 * MB, rss: 700 * MB } as NodeJS.MemoryUsage)
+ spy.mockReturnValue({
+ arrayBuffers: 0,
+ external: 0,
+ heapTotal: 700 * MB,
+ heapUsed: 700 * MB,
+ rss: 700 * MB
+ } as NodeJS.MemoryUsage)
await vi.advanceTimersByTimeAsync(2)
expect(onWarn).not.toHaveBeenCalled()
diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts
index 1ad2b788df7..fc577ab5804 100644
--- a/ui-tui/src/__tests__/messages.test.ts
+++ b/ui-tui/src/__tests__/messages.test.ts
@@ -1,6 +1,7 @@
+import { PassThrough } from 'stream'
+
import { renderSync } from '@hermes/ink'
import React from 'react'
-import { PassThrough } from 'stream'
import { describe, expect, it } from 'vitest'
import { MessageLine } from '../components/messageLine.js'
diff --git a/ui-tui/src/__tests__/parentLog.test.ts b/ui-tui/src/__tests__/parentLog.test.ts
index 2a910c7cfd9..d4f9d342a03 100644
--- a/ui-tui/src/__tests__/parentLog.test.ts
+++ b/ui-tui/src/__tests__/parentLog.test.ts
@@ -45,7 +45,9 @@ describe('recordParentLifecycle', () => {
recordParentLifecycle('uncaughtException: boom\n at foo()\r\n at bar()')
- const lines = readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8').trimEnd().split('\n')
+ const lines = readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8')
+ .trimEnd()
+ .split('\n')
expect(lines).toHaveLength(1)
expect(lines[0]).toContain('boom ↵ at foo() ↵ at bar()')
diff --git a/ui-tui/src/__tests__/platform.test.ts b/ui-tui/src/__tests__/platform.test.ts
index 77f1347a3af..a5da6985eb5 100644
--- a/ui-tui/src/__tests__/platform.test.ts
+++ b/ui-tui/src/__tests__/platform.test.ts
@@ -334,7 +334,9 @@ describe('parseVoiceRecordKey (#18994)', () => {
// Some terminals surface bare Esc as meta=true + escape=true.
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', altEscape)).toBe(false)
// Explicit alt bit (kitty-style) still fires the configured chord.
- expect(isVoiceToggleKey({ alt: true, ctrl: false, escape: true, meta: false, super: false }, '', altEscape)).toBe(true)
+ expect(isVoiceToggleKey({ alt: true, ctrl: false, escape: true, meta: false, super: false }, '', altEscape)).toBe(
+ true
+ )
})
it('rejects matches when Shift is held (different chord than configured)', async () => {
@@ -348,7 +350,9 @@ describe('parseVoiceRecordKey (#18994)', () => {
const ctrlO = parseVoiceRecordKey('ctrl+o')
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false, tab: true }, '', ctrlTab)).toBe(false)
- expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, return: true, shift: true, super: false }, '', altEnter)).toBe(false)
+ expect(
+ isVoiceToggleKey({ alt: true, ctrl: false, meta: false, return: true, shift: true, super: false }, '', altEnter)
+ ).toBe(false)
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false }, 'o', ctrlO)).toBe(false)
// Sanity: same events without Shift still fire.
diff --git a/ui-tui/src/__tests__/terminalModes.test.ts b/ui-tui/src/__tests__/terminalModes.test.ts
index 90d551a3dfd..d621f4eef44 100644
--- a/ui-tui/src/__tests__/terminalModes.test.ts
+++ b/ui-tui/src/__tests__/terminalModes.test.ts
@@ -4,8 +4,8 @@ import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js
describe('terminal mode reset', () => {
it('includes common sticky input modes', () => {
- expect(TERMINAL_MODE_RESET).toContain('\x1b[0\'z')
- expect(TERMINAL_MODE_RESET).toContain('\x1b[0\'{')
+ expect(TERMINAL_MODE_RESET).toContain("\x1b[0'z")
+ expect(TERMINAL_MODE_RESET).toContain("\x1b[0'{")
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2029l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1016l')
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1015l')
@@ -54,6 +54,7 @@ describe('terminal mode reset', () => {
expect(write).toHaveBeenCalledTimes(1)
const written = write.mock.calls[0]?.[0] as string
+
for (const mode of ['\x1b[?1006l', '\x1b[?1003l', '\x1b[?1002l', '\x1b[?1000l']) {
expect(written).toContain(mode)
}
diff --git a/ui-tui/src/__tests__/termux.test.ts b/ui-tui/src/__tests__/termux.test.ts
index 2fe0573d5aa..d8d4ef8348c 100644
--- a/ui-tui/src/__tests__/termux.test.ts
+++ b/ui-tui/src/__tests__/termux.test.ts
@@ -8,9 +8,7 @@ describe('isTermuxEnv', () => {
})
it('detects Termux PREFIX path marker', () => {
- expect(
- isTermuxEnv({ PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv)
- ).toBe(true)
+ expect(isTermuxEnv({ PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv)).toBe(true)
})
it('returns false for generic Linux envs', () => {
@@ -24,9 +22,7 @@ describe('isTermuxTuiMode', () => {
})
it('allows explicit opt-out override', () => {
- expect(
- isTermuxTuiMode({ TERMUX_VERSION: '0.118.0', HERMES_TUI_TERMUX_MODE: '0' } as NodeJS.ProcessEnv)
- ).toBe(false)
+ expect(isTermuxTuiMode({ TERMUX_VERSION: '0.118.0', HERMES_TUI_TERMUX_MODE: '0' } as NodeJS.ProcessEnv)).toBe(false)
})
it('stays false outside Termux even if override is set', () => {
diff --git a/ui-tui/src/__tests__/textInputBurstInput.test.ts b/ui-tui/src/__tests__/textInputBurstInput.test.ts
index 1fdd5246614..7614abf4543 100644
--- a/ui-tui/src/__tests__/textInputBurstInput.test.ts
+++ b/ui-tui/src/__tests__/textInputBurstInput.test.ts
@@ -6,10 +6,10 @@ describe('applyPrintableInsert', () => {
it('applies non-bracketed multi-character bursts immediately', () => {
const burst = applyPrintableInsert('abc', 3, 'xxxxx')
- const repeated = [...'xxxxx'].reduce(
- (state, ch) => applyPrintableInsert(state.value, state.cursor, ch)!,
- { cursor: 3, value: 'abc' }
- )
+ const repeated = [...'xxxxx'].reduce((state, ch) => applyPrintableInsert(state.value, state.cursor, ch)!, {
+ cursor: 3,
+ value: 'abc'
+ })
expect(burst).toEqual({ cursor: 8, value: 'abcxxxxx' })
expect(burst).toEqual(repeated)
diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts
index 98928d1baf1..75ed282a8a0 100644
--- a/ui-tui/src/__tests__/textInputFastEcho.test.ts
+++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts
@@ -217,7 +217,10 @@ describe('supportsFastEchoTerminal', () => {
it('disables fast-echo by default in Termux mode', () => {
expect(
- supportsFastEchoTerminal({ TERMUX_VERSION: '0.118.0', PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv)
+ supportsFastEchoTerminal({
+ TERMUX_VERSION: '0.118.0',
+ PREFIX: '/data/data/com.termux/files/usr'
+ } as NodeJS.ProcessEnv)
).toBe(false)
})
diff --git a/ui-tui/src/__tests__/textInputPassThrough.test.ts b/ui-tui/src/__tests__/textInputPassThrough.test.ts
index 1fb47779b0f..05e214c0c19 100644
--- a/ui-tui/src/__tests__/textInputPassThrough.test.ts
+++ b/ui-tui/src/__tests__/textInputPassThrough.test.ts
@@ -3,8 +3,7 @@ import { describe, expect, it } from 'vitest'
import { shouldPassThroughToGlobalHandler, shouldPreserveCtrlJNewline } from '../components/textInput.js'
import { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } from '../lib/platform.js'
-const key = (overrides: Record = {}) =>
- ({ ctrl: false, meta: false, ...overrides }) as any
+const key = (overrides: Record = {}) => ({ ctrl: false, meta: false, ...overrides }) as any
describe('shouldPreserveCtrlJNewline', () => {
it('preserves Ctrl+J as newline in Ghostty even when tmux masks TERM/TERM_PROGRAM', () => {
@@ -24,15 +23,9 @@ describe('shouldPreserveCtrlJNewline', () => {
describe('shouldPassThroughToGlobalHandler', () => {
it('passes through the configured voice shortcut while composer is focused', () => {
- expect(
- shouldPassThroughToGlobalHandler('o', key({ ctrl: true }), parseVoiceRecordKey('ctrl+o'))
- ).toBe(true)
- expect(
- shouldPassThroughToGlobalHandler('r', key({ meta: true }), parseVoiceRecordKey('alt+r'))
- ).toBe(true)
- expect(
- shouldPassThroughToGlobalHandler(' ', key({ ctrl: true }), parseVoiceRecordKey('ctrl+space'))
- ).toBe(true)
+ expect(shouldPassThroughToGlobalHandler('o', key({ ctrl: true }), parseVoiceRecordKey('ctrl+o'))).toBe(true)
+ expect(shouldPassThroughToGlobalHandler('r', key({ meta: true }), parseVoiceRecordKey('alt+r'))).toBe(true)
+ expect(shouldPassThroughToGlobalHandler(' ', key({ ctrl: true }), parseVoiceRecordKey('ctrl+space'))).toBe(true)
expect(
shouldPassThroughToGlobalHandler('', key({ ctrl: true, return: true }), parseVoiceRecordKey('ctrl+enter'))
).toBe(true)
diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts
index d45576698dd..6e356ab3a95 100644
--- a/ui-tui/src/__tests__/theme.test.ts
+++ b/ui-tui/src/__tests__/theme.test.ts
@@ -220,10 +220,13 @@ describe('fromSkin', () => {
it('maps completion meta background colors from skins', async () => {
const { fromSkin } = await importThemeWithCleanEnv()
- const theme = fromSkin({
- completion_menu_meta_bg: '#111111',
- completion_menu_meta_current_bg: '#222222'
- }, {})
+ const theme = fromSkin(
+ {
+ completion_menu_meta_bg: '#111111',
+ completion_menu_meta_current_bg: '#222222'
+ },
+ {}
+ )
expect(theme.color.completionMetaBg).toBe('#111111')
expect(theme.color.completionMetaCurrentBg).toBe('#222222')
@@ -263,14 +266,17 @@ describe('fromSkin', () => {
it('normalizes non-banner foregrounds on light Apple Terminal', async () => {
const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' })
- const theme = fromSkin({
- banner_accent: '#FFBF00',
- banner_border: '#CD7F32',
- banner_dim: '#B8860B',
- banner_text: '#FFF8DC',
- banner_title: '#FFD700',
- prompt: '#FFF8DC'
- }, {})
+ const theme = fromSkin(
+ {
+ banner_accent: '#FFBF00',
+ banner_border: '#CD7F32',
+ banner_dim: '#B8860B',
+ banner_text: '#FFF8DC',
+ banner_title: '#FFD700',
+ prompt: '#FFF8DC'
+ },
+ {}
+ )
expect(theme.color.primary).toBe('#FFD700')
expect(theme.color.accent).toBe('#FFBF00')
diff --git a/ui-tui/src/__tests__/turnControllerNotice.test.ts b/ui-tui/src/__tests__/turnControllerNotice.test.ts
index 7ef224aee2a..33459046d43 100644
--- a/ui-tui/src/__tests__/turnControllerNotice.test.ts
+++ b/ui-tui/src/__tests__/turnControllerNotice.test.ts
@@ -35,7 +35,12 @@ describe('turnController.startMessage — flash-and-yield notices clear on next
it('leaves a sticky credits.depleted notice across a new turn', () => {
patchUiState({
- notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /credits to top up' }
+ notice: {
+ key: 'credits.depleted',
+ kind: 'sticky',
+ level: 'error',
+ text: '✕ Credit access paused · run /credits to top up'
+ }
})
turnController.startMessage()
expect(getUiState().notice?.key).toBe('credits.depleted')
diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts
index c82984bac4d..e760e8d748a 100644
--- a/ui-tui/src/__tests__/useConfigSync.test.ts
+++ b/ui-tui/src/__tests__/useConfigSync.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $uiState, resetUiState } from '../app/uiStore.js'
import {
@@ -9,7 +9,6 @@ import {
normalizeMouseTracking,
normalizeStatusBar
} from '../app/useConfigSync.js'
-import type { ParsedVoiceRecordKey } from '../lib/platform.js'
describe('applyDisplay', () => {
beforeEach(() => {
@@ -332,11 +331,7 @@ describe('applyDisplay → voice.record_key (#18994)', () => {
const setBell = vi.fn()
const setVoiceRecordKey = vi.fn()
- applyDisplay(
- { config: { display: {}, voice: { record_key: 'ctrl+space' } } },
- setBell,
- setVoiceRecordKey
- )
+ applyDisplay({ config: { display: {}, voice: { record_key: 'ctrl+space' } } }, setBell, setVoiceRecordKey)
expect(setVoiceRecordKey).toHaveBeenCalledWith(
expect.objectContaining({ ch: 'space', mod: 'ctrl', named: 'space', raw: 'ctrl+space' })
@@ -349,9 +344,7 @@ describe('applyDisplay → voice.record_key (#18994)', () => {
applyDisplay({ config: { display: {} } }, setBell, setVoiceRecordKey)
- expect(setVoiceRecordKey).toHaveBeenCalledWith(
- expect.objectContaining({ ch: 'b', mod: 'ctrl', raw: 'ctrl+b' })
- )
+ expect(setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'b', mod: 'ctrl', raw: 'ctrl+b' }))
})
it('is a no-op when the voice setter is not passed (back-compat)', () => {
@@ -359,9 +352,7 @@ describe('applyDisplay → voice.record_key (#18994)', () => {
// applyDisplay is used in the setVoiceEnabled-less init path too;
// omitting the third arg must not throw.
- expect(() =>
- applyDisplay({ config: { display: {}, voice: { record_key: 'alt+r' } } }, setBell)
- ).not.toThrow()
+ expect(() => applyDisplay({ config: { display: {}, voice: { record_key: 'alt+r' } } }, setBell)).not.toThrow()
})
it('does not reset voiceRecordKey when cfg is null (transient RPC failure)', () => {
@@ -406,9 +397,7 @@ describe('hydrateFullConfig', () => {
await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
expect(gw.request).toHaveBeenCalledWith('config.get', { key: 'full' })
- expect(setVoiceRecordKey).toHaveBeenCalledWith(
- expect.objectContaining({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' })
- )
+ expect(setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' }))
expect(setBell).toHaveBeenCalledWith(false)
})
diff --git a/ui-tui/src/__tests__/useSessionLifecycle.test.ts b/ui-tui/src/__tests__/useSessionLifecycle.test.ts
index 7a7e11c8758..46db4889554 100644
--- a/ui-tui/src/__tests__/useSessionLifecycle.test.ts
+++ b/ui-tui/src/__tests__/useSessionLifecycle.test.ts
@@ -7,7 +7,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { patchUiState, resetUiState } from '../app/uiStore.js'
-import { hydrateLiveSessionInflight, liveSessionInflightMessages, writeActiveSessionFile } from '../app/useSessionLifecycle.js'
+import {
+ hydrateLiveSessionInflight,
+ liveSessionInflightMessages,
+ writeActiveSessionFile
+} from '../app/useSessionLifecycle.js'
describe('writeActiveSessionFile', () => {
let dir = ''
@@ -29,7 +33,6 @@ describe('writeActiveSessionFile', () => {
})
})
-
describe('live session activation in-flight state', () => {
beforeEach(() => {
resetUiState()
diff --git a/ui-tui/src/__tests__/viewportStore.test.ts b/ui-tui/src/__tests__/viewportStore.test.ts
index 2d37127e546..7a571fb95ad 100644
--- a/ui-tui/src/__tests__/viewportStore.test.ts
+++ b/ui-tui/src/__tests__/viewportStore.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
-import { getScrollbarSnapshot, getViewportSnapshot, scrollbarSnapshotKey, viewportSnapshotKey } from '../lib/viewportStore.js'
+import {
+ getScrollbarSnapshot,
+ getViewportSnapshot,
+ scrollbarSnapshotKey,
+ viewportSnapshotKey
+} from '../lib/viewportStore.js'
describe('viewportStore', () => {
it('normalizes absent scroll handles', () => {
diff --git a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts
index a98b43972e6..010d12c9ca8 100644
--- a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts
+++ b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts
@@ -42,7 +42,11 @@ const mountedSpan = (items: readonly Item[], virtualHistory: ReturnType, scroll: ScrollBoxHandle) => {
+const viewportIsMounted = (
+ items: readonly Item[],
+ virtualHistory: ReturnType,
+ scroll: ScrollBoxHandle
+) => {
const span = mountedSpan(items, virtualHistory)
const top = scroll.getScrollTop()
const bottom = top + scroll.getViewportHeight()
@@ -86,19 +90,17 @@ function Harness({
Box,
{ flexDirection: 'column', width: '100%' },
virtualHistory.topSpacer > 0 ? React.createElement(Box, { height: virtualHistory.topSpacer }) : null,
- ...items
- .slice(virtualHistory.start, virtualHistory.end)
- .map(item =>
- React.createElement(
- Box,
- {
- height: itemHeightForColumns(item, columns),
- key: item.key,
- ref: virtualHistory.measureRef(item.key)
- },
- React.createElement(Text, null, item.key)
- )
- ),
+ ...items.slice(virtualHistory.start, virtualHistory.end).map(item =>
+ React.createElement(
+ Box,
+ {
+ height: itemHeightForColumns(item, columns),
+ key: item.key,
+ ref: virtualHistory.measureRef(item.key)
+ },
+ React.createElement(Text, null, item.key)
+ )
+ ),
virtualHistory.bottomSpacer > 0 ? React.createElement(Box, { height: virtualHistory.bottomSpacer }) : null
)
)
diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts
index b0b09a725c3..45532b2058d 100644
--- a/ui-tui/src/app/createGatewayEventHandler.ts
+++ b/ui-tui/src/app/createGatewayEventHandler.ts
@@ -10,8 +10,8 @@ import type {
SessionMostRecentResponse
} from '../gatewayTypes.js'
import { isTodoDone } from '../lib/liveProgress.js'
-import { rpcErrorMessage } from '../lib/rpc.js'
import { openExternalUrl } from '../lib/openExternalUrl.js'
+import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js'
import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js'
import { fromSkin } from '../theme.js'
@@ -553,13 +553,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
sys('💳 Open this link to grant terminal billing access:')
sys(url)
+
if (code) {
sys(`If prompted, enter code: ${code}`)
}
+
void openExternalUrl(url)
return
}
+
case 'gateway.stderr': {
const line = String(ev.payload.line).slice(0, 120)
diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts
index 044200d6b90..a164511c758 100644
--- a/ui-tui/src/app/createSlashHandler.ts
+++ b/ui-tui/src/app/createSlashHandler.ts
@@ -99,6 +99,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
if (d.notice?.trim()) {
sys(d.notice)
}
+
return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`)
}
@@ -109,6 +110,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
if (d.notice?.trim()) {
sys(d.notice)
}
+
if (d.message) {
ctx.composer.setInput(d.message)
}
diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts
index b716b313c90..58f29e4bdb1 100644
--- a/ui-tui/src/app/overlayStore.ts
+++ b/ui-tui/src/app/overlayStore.ts
@@ -23,21 +23,35 @@ export const $overlayState = atom(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
- ({ agents, approval, billing, clarify, confirm, modelPicker, pager, petPicker, pluginsHub, secret, sessions, skillsHub, sudo }) =>
+ ({
+ agents,
+ approval,
+ billing,
+ clarify,
+ confirm,
+ modelPicker,
+ pager,
+ petPicker,
+ pluginsHub,
+ secret,
+ sessions,
+ skillsHub,
+ sudo
+ }) =>
Boolean(
agents ||
- approval ||
- billing ||
- clarify ||
- confirm ||
- modelPicker ||
- pager ||
- petPicker ||
- pluginsHub ||
- secret ||
- sessions ||
- skillsHub ||
- sudo
+ approval ||
+ billing ||
+ clarify ||
+ confirm ||
+ modelPicker ||
+ pager ||
+ petPicker ||
+ pluginsHub ||
+ secret ||
+ sessions ||
+ skillsHub ||
+ sudo
)
)
diff --git a/ui-tui/src/app/petFlashStore.ts b/ui-tui/src/app/petFlashStore.ts
index d4865e47a2b..328fcfc8b04 100644
--- a/ui-tui/src/app/petFlashStore.ts
+++ b/ui-tui/src/app/petFlashStore.ts
@@ -12,5 +12,4 @@ interface PetFlash {
// sets these; usePet reads them with priority over the derived state.
export const $petFlash = atom(null)
-export const flashPet = (state: PetState, ms = 1600) =>
- $petFlash.set({ state, until: Date.now() + ms })
+export const flashPet = (state: PetState, ms = 1600) => $petFlash.set({ state, until: Date.now() + ms })
diff --git a/ui-tui/src/app/slash/commands/billing.ts b/ui-tui/src/app/slash/commands/billing.ts
index 6c3ddec0845..5bcb7a38ac7 100644
--- a/ui-tui/src/app/slash/commands/billing.ts
+++ b/ui-tui/src/app/slash/commands/billing.ts
@@ -48,14 +48,18 @@ const renderBillingError = (
sys('🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.')
break
-
case 'monthly_cap_exceeded': {
// Surface the remaining headroom the server attaches (parity with the CLI).
const remaining = env.payload?.remainingUsd
- sys(remaining != null ? `🔴 Monthly spend cap reached — $${remaining} headroom left.` : '🔴 Monthly spend cap reached.')
+ sys(
+ remaining != null
+ ? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
+ : '🔴 Monthly spend cap reached.'
+ )
break
}
+
case 'rate_limited': {
const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : ''
sys(`🟡 Too many charges right now${mins}. This isn't a payment failure.`)
@@ -105,6 +109,7 @@ const armStepUp = (sys: Sys, ctx: SlashRunCtx): void => {
'🟡 Permission granted, but terminal billing is still turned off ' +
'for this org. Enable it in the portal, then run /billing again.'
)
+
if (s.portal_url) {
sys(`Portal: ${s.portal_url}`)
}
@@ -177,6 +182,7 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st
'🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' +
'Check /billing or the portal shortly.'
)
+
if (portalUrl) {
sys(`Portal: ${portalUrl}`)
}
diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts
index d87a1ec7513..8bd6f553d81 100644
--- a/ui-tui/src/app/slash/commands/core.ts
+++ b/ui-tui/src/app/slash/commands/core.ts
@@ -385,9 +385,7 @@ export const coreCommands: SlashCommand[] = [
if (text) {
return sys(`copied ${text.length} characters`)
} else {
- return sys(
- 'clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence'
- )
+ return sys('clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence')
}
}
diff --git a/ui-tui/src/app/slash/commands/credits.ts b/ui-tui/src/app/slash/commands/credits.ts
index c653916d2de..195eb7d105f 100644
--- a/ui-tui/src/app/slash/commands/credits.ts
+++ b/ui-tui/src/app/slash/commands/credits.ts
@@ -14,6 +14,7 @@ export const creditsCommands: SlashCommand[] = [
ctx.guarded(view => {
if (!view.logged_in) {
ctx.transcript.sys('💳 Not logged into Nous Portal — run /portal to log in.')
+
return
}
diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts
index ad41a04977f..969ef444463 100644
--- a/ui-tui/src/app/slash/commands/ops.ts
+++ b/ui-tui/src/app/slash/commands/ops.ts
@@ -87,9 +87,11 @@ export const opsCommands: SlashCommand[] = [
// Parse arg: `now` / `always` skip the confirmation gate.
// `always` additionally persists approvals.mcp_reload_confirm=false.
const a = (arg || '').trim().toLowerCase()
+
const params: { session_id: string | null; confirm?: boolean; always?: boolean } = {
session_id: ctx.sid
}
+
if (a === 'now' || a === 'approve' || a === 'once' || a === 'yes') {
params.confirm = true
} else if (a === 'always') {
@@ -103,16 +105,20 @@ export const opsCommands: SlashCommand[] = [
ctx.guarded(r => {
if (r.status === 'confirm_required') {
ctx.transcript.sys(r.message || '/reload-mcp requires confirmation')
+
return
}
+
if (r.status === 'reloaded') {
ctx.transcript.sys(
params.always
? 'MCP servers reloaded · future /reload-mcp will run without confirmation'
: 'MCP servers reloaded'
)
+
return
}
+
ctx.transcript.sys('reload complete')
})
)
@@ -488,6 +494,7 @@ export const opsCommands: SlashCommand[] = [
const query = rest.join(' ').trim()
const { rpc } = ctx.gateway
const { panel, sys } = ctx.transcript
+
const runViaSlashWorker = () => {
ctx.gateway.gw
.request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts
index 27848eaf69d..94e1b38c5de 100644
--- a/ui-tui/src/app/slash/commands/session.ts
+++ b/ui-tui/src/app/slash/commands/session.ts
@@ -73,38 +73,44 @@ export const sessionCommands: SlashCommand[] = [
return patchOverlayState({ modelPicker: true })
}
- const switchModel = (confirmExpensiveModel = false) => ctx.gateway
- .rpc('config.set', { confirm_expensive_model: confirmExpensiveModel, key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) })
- .then(
- ctx.guarded(r => {
- if (r.confirm_required) {
- patchOverlayState({
- confirm: {
- cancelLabel: 'Cancel',
- confirmLabel: 'Switch anyway',
- danger: true,
- detail: r.confirm_message || r.warning || 'This model has unusually high known pricing.',
- onConfirm: () => switchModel(true),
- title: 'Expensive model selection'
- }
- })
-
- return
- }
-
- if (!r.value) {
- return ctx.transcript.sys('error: invalid response: model switch')
- }
-
- ctx.transcript.sys(`model → ${r.value}`)
- ctx.local.maybeWarn(r)
-
- patchUiState(state => ({
- ...state,
- info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} }
- }))
+ const switchModel = (confirmExpensiveModel = false) =>
+ ctx.gateway
+ .rpc('config.set', {
+ confirm_expensive_model: confirmExpensiveModel,
+ key: 'model',
+ session_id: ctx.sid,
+ value: modelValueForConfigSet(arg)
})
- )
+ .then(
+ ctx.guarded(r => {
+ if (r.confirm_required) {
+ patchOverlayState({
+ confirm: {
+ cancelLabel: 'Cancel',
+ confirmLabel: 'Switch anyway',
+ danger: true,
+ detail: r.confirm_message || r.warning || 'This model has unusually high known pricing.',
+ onConfirm: () => switchModel(true),
+ title: 'Expensive model selection'
+ }
+ })
+
+ return
+ }
+
+ if (!r.value) {
+ return ctx.transcript.sys('error: invalid response: model switch')
+ }
+
+ ctx.transcript.sys(`model → ${r.value}`)
+ ctx.local.maybeWarn(r)
+
+ patchUiState(state => ({
+ ...state,
+ info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} }
+ }))
+ })
+ )
switchModel()
}
@@ -443,31 +449,29 @@ export const sessionCommands: SlashCommand[] = [
)
}
- ctx.gateway
- .rpc('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg })
- .then(
- ctx.guarded(r => {
- if (!r.value) {
- return
- }
+ ctx.gateway.rpc('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }).then(
+ ctx.guarded(r => {
+ if (!r.value) {
+ return
+ }
- if (r.value === 'hide') {
- patchUiState(state => ({
- ...state,
- sections: { ...state.sections, thinking: 'hidden' },
- showReasoning: false
- }))
- } else if (r.value === 'show') {
- patchUiState(state => ({
- ...state,
- sections: { ...state.sections, thinking: 'expanded' },
- showReasoning: true
- }))
- }
+ if (r.value === 'hide') {
+ patchUiState(state => ({
+ ...state,
+ sections: { ...state.sections, thinking: 'hidden' },
+ showReasoning: false
+ }))
+ } else if (r.value === 'show') {
+ patchUiState(state => ({
+ ...state,
+ sections: { ...state.sections, thinking: 'expanded' },
+ showReasoning: true
+ }))
+ }
- ctx.transcript.sys(`reasoning: ${r.value}`)
- })
- )
+ ctx.transcript.sys(`reasoning: ${r.value}`)
+ })
+ )
}
},
diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts
index c9192f5d56d..64759593711 100644
--- a/ui-tui/src/app/slash/registry.ts
+++ b/ui-tui/src/app/slash/registry.ts
@@ -1,5 +1,5 @@
-import { coreCommands } from './commands/core.js'
import { billingCommands } from './commands/billing.js'
+import { coreCommands } from './commands/core.js'
import { creditsCommands } from './commands/credits.js'
import { debugCommands } from './commands/debug.js'
import { opsCommands } from './commands/ops.js'
diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts
index 9e713cc4bec..39dd71ab696 100644
--- a/ui-tui/src/app/turnController.ts
+++ b/ui-tui/src/app/turnController.ts
@@ -772,7 +772,13 @@ class TurnController {
done?.verboseArgs,
error || resultText || summary || ''
)
- : buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '', duration ?? fallbackDuration)
+ : buildToolTrailLine(
+ name,
+ done?.context || '',
+ Boolean(error),
+ error || summary || '',
+ duration ?? fallbackDuration
+ )
this.activeTools = this.activeTools.filter(tool => tool.id !== toolId)
@@ -915,9 +921,11 @@ class TurnController {
// sticky until the policy clears them. The Python `active` latch retains the key,
// so a yielded notice won't re-fire on the next turn.
const yieldingNoticeKey = getUiState().notice?.key
+
if (yieldingNoticeKey === 'credits.usage' || yieldingNoticeKey === 'credits.grant_spent') {
this.clearNotice(yieldingNoticeKey)
}
+
patchUiState({ busy: true })
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
}
diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts
index 6d964213f25..f845b7f2065 100644
--- a/ui-tui/src/app/useConfigSync.ts
+++ b/ui-tui/src/app/useConfigSync.ts
@@ -3,16 +3,8 @@ import { useEffect, useRef } from 'react'
import { resolveDetailsMode, resolveSections } from '../domain/details.js'
import type { GatewayClient } from '../gatewayClient.js'
-import type {
- ConfigFullResponse,
- ConfigMtimeResponse,
- ReloadMcpResponse
-} from '../gatewayTypes.js'
-import {
- DEFAULT_VOICE_RECORD_KEY,
- type ParsedVoiceRecordKey,
- parseVoiceRecordKey
-} from '../lib/platform.js'
+import type { ConfigFullResponse, ConfigMtimeResponse, ReloadMcpResponse } from '../gatewayTypes.js'
+import { DEFAULT_VOICE_RECORD_KEY, type ParsedVoiceRecordKey, parseVoiceRecordKey } from '../lib/platform.js'
import { asRpcResult } from '../lib/rpc.js'
import {
@@ -143,24 +135,46 @@ const _voiceRecordKeyFromConfig = (cfg: ConfigFullResponse | null): ParsedVoiceR
}
const _pasteCollapseLinesFromConfig = (cfg: ConfigFullResponse | null): number => {
- if (!cfg?.config) return 5
+ if (!cfg?.config) {
+ return 5
+ }
+
const raw = cfg.config.paste_collapse_threshold
- if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw)
+
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
+ return Math.round(raw)
+ }
+
if (typeof raw === 'string') {
const n = parseInt(raw, 10)
- if (Number.isFinite(n) && n >= 0) return n
+
+ if (Number.isFinite(n) && n >= 0) {
+ return n
+ }
}
+
return 5
}
const _pasteCollapseCharsFromConfig = (cfg: ConfigFullResponse | null): number => {
- if (!cfg?.config) return 2000
+ if (!cfg?.config) {
+ return 2000
+ }
+
const raw = cfg.config.paste_collapse_char_threshold
- if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw)
+
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
+ return Math.round(raw)
+ }
+
if (typeof raw === 'string') {
const n = parseInt(raw, 10)
- if (Number.isFinite(n) && n >= 0) return n
+
+ if (Number.isFinite(n) && n >= 0) {
+ return n
+ }
}
+
return 2000
}
diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts
index b0db1e1f945..19da0daf210 100644
--- a/ui-tui/src/app/useMainApp.ts
+++ b/ui-tui/src/app/useMainApp.ts
@@ -476,11 +476,14 @@ export function useMainApp(gw: GatewayClient) {
process.exit(0)
}, [exit, gw])
- const dieWithCode = useCallback((code: number) => {
- gw.kill(`app.dieWithCode:${code}`)
- exit()
- process.exit(code)
- }, [exit, gw])
+ const dieWithCode = useCallback(
+ (code: number) => {
+ gw.kill(`app.dieWithCode:${code}`)
+ exit()
+ process.exit(code)
+ },
+ [exit, gw]
+ )
const session = useSessionLifecycle({
colsRef,
@@ -534,8 +537,7 @@ export function useMainApp(gw: GatewayClient) {
// round-trip is needed.
const currentSid = getUiState().sid
- const sessionTitle =
- result.sessions.find(s => s.current || s.id === currentSid)?.title?.trim() ?? ''
+ const sessionTitle = result.sessions.find(s => s.current || s.id === currentSid)?.title?.trim() ?? ''
// Only patch when something actually changed. patchUiState always
// produces a new state object, which notifies every $uiState
@@ -759,7 +761,6 @@ export function useMainApp(gw: GatewayClient) {
[
appendMessage,
bellOnComplete,
- clearSelection,
composerActions.setInput,
gateway,
panel,
@@ -867,6 +868,7 @@ export function useMainApp(gw: GatewayClient) {
composerActions,
composerRefs,
die,
+ dieWithCode,
gateway,
hasSelection,
maybeWarn,
@@ -1055,10 +1057,7 @@ export function useMainApp(gw: GatewayClient) {
closeLiveSession,
newPromptSession,
onModelSelect,
- session.activateLiveSession,
- session.guardBusySessionSwitch,
- session.newLiveSession,
- session.resumeById
+ session
]
)
@@ -1104,7 +1103,11 @@ export function useMainApp(gw: GatewayClient) {
turnStartedAt: ui.sid ? turnStartedAt : null,
// CLI parity: the classic prompt_toolkit status bar shows a red dot
// on REC (cli.py:_get_voice_status_fragments line 2344).
- voiceLabel: voiceRecording ? '● REC' : voiceProcessing ? '◉ STT' : `voice ${voiceEnabled ? 'on' : 'off'}${voiceTts ? ' [tts]' : ''}`
+ voiceLabel: voiceRecording
+ ? '● REC'
+ : voiceProcessing
+ ? '◉ STT'
+ : `voice ${voiceEnabled ? 'on' : 'off'}${voiceTts ? ' [tts]' : ''}`
}),
[
cwd,
diff --git a/ui-tui/src/app/usePet.ts b/ui-tui/src/app/usePet.ts
index 8943c1ae7f0..01196821fc4 100644
--- a/ui-tui/src/app/usePet.ts
+++ b/ui-tui/src/app/usePet.ts
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { PetGrid } from '../components/petSprite.js'
import { useGateway } from './gatewayContext.js'
-import { getOverlayState, $overlayState } from './overlayStore.js'
+import { $overlayState, getOverlayState } from './overlayStore.js'
import { $petFlash } from './petFlashStore.js'
import { $turnState } from './turnStore.js'
import { $uiState } from './uiStore.js'
diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx
index 68fa44e3a44..af4fbbb27fb 100644
--- a/ui-tui/src/components/activeSessionSwitcher.tsx
+++ b/ui-tui/src/components/activeSessionSwitcher.tsx
@@ -44,8 +44,7 @@ const ctrlChar = (letter: string) => String.fromCharCode(letter.charCodeAt(0) -
export const fixedSessionColumnStyle = () => ({ flexShrink: 0 })
-export const activeSessionCountLabel = (count: number) =>
- `${count} live ${count === 1 ? 'session' : 'sessions'}`
+export const activeSessionCountLabel = (count: number) => `${count} live ${count === 1 ? 'session' : 'sessions'}`
export const sessionsCountLabel = (liveCount: number, resumableCount: number) =>
`${liveCount} live · ${resumableCount} resumable`
@@ -229,6 +228,7 @@ export const draftModelNameFromArg = (value: string) => {
if (part === '--provider') {
i++
+
continue
}
@@ -360,6 +360,7 @@ export function ActiveSessionSwitcher({
}),
includeHistory ? gw.request('session.list', { limit: 200 }) : Promise.resolve(null)
])
+
const r = liveRes.status === 'fulfilled' ? asRpcResult(liveRes.value) : null
if (!r) {
@@ -699,12 +700,7 @@ export function ActiveSessionSwitcher({
{err && error: {err}}
-
+
{newSelectedRow ? '▸ ' : ' '}
@@ -752,6 +748,7 @@ export function ActiveSessionSwitcher({
if (kind === 'history') {
const h = history[i - 1 - items.length]!
const pendingDelete = confirmDelete === h.id
+
const title = pendingDelete
? 'press d again to delete'
: deleting && selected
@@ -797,7 +794,7 @@ export function ActiveSessionSwitcher({
{title}
@@ -883,7 +880,9 @@ export function ActiveSessionSwitcher({
) : (
diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx
index 9992b227390..14d43dd367d 100644
--- a/ui-tui/src/components/appChrome.tsx
+++ b/ui-tui/src/components/appChrome.tsx
@@ -393,7 +393,7 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
const id = setTimeout(() => setActive(false), 650)
return () => clearTimeout(id)
- }, [t.color.accent, tick])
+ }, [t.color.accent, t.color.error, t.color.warn, tick])
if (!active) {
return null
@@ -479,6 +479,7 @@ export function StatusRule({
// mid-segment, so status/model/context are never crushed.
const SEP = stringWidth(' │ ')
let tailBudget = Math.max(0, leftWidth - essentialWidth)
+
const fits = (w: number) => {
if (tailBudget >= w) {
tailBudget -= w
@@ -491,6 +492,7 @@ export function StatusRule({
const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : ''
const compressions = typeof usage.compressions === 'number' ? usage.compressions : 0
+
// Dev-only readout (HERMES_DEV_CREDITS). The server omits the key entirely unless the
// flag is on, so this segment self-hides for normal users. micros→cents is allowed money
// math (display formatting) — never parseFloat a *_usd. Signed: a mid-session top-up that
@@ -502,16 +504,20 @@ export function StatusRule({
const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`))
const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH)
+
// Idle clock — time since the last final agent response. Hidden while busy
// (the FaceTicker's elapsed tail covers the live turn) and before the first
// turn completes. Shares the duration breakpoint and width reservation.
- const showIdle = segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH)
+ const showIdle =
+ segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH)
+
const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`))
const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
const subagentCount = typeof usage.active_subagents === 'number' ? usage.active_subagents : 0
const showSubagents = segs.subagents && subagentCount > 0 && fits(SEP + stringWidth(`⛓ ${subagentCount}`))
+
// Parked-background reassurance: a top-level delegate_task runs in the
// background, so the turn ends (idle) while the subagent keeps working and its
// result re-enters as a fresh turn later. When idle with work still in flight,
@@ -520,6 +526,7 @@ export function StatusRule({
// terminal where ⛓ already carries the signal.
const resumeHintText =
subagentCount === 1 ? '↩ resumes when subagent finishes' : `↩ resumes when ${subagentCount} subagents finish`
+
const showResumeHint = !busy && subagentCount > 0 && fits(SEP + stringWidth(resumeHintText))
// Dev-gated readout (HERMES_DEV_CREDITS), lowest priority,
// so it consumes tail budget LAST and drops first on a narrow terminal.
@@ -629,8 +636,7 @@ export function StatusRule({
) : null}
{showSubagents ? (
- {' │ '}
- ⛓ {subagentCount}
+ {' │ '}⛓ {subagentCount}
) : null}
{showResumeHint ? (
diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx
index d3cd8383d52..66961f70e3f 100644
--- a/ui-tui/src/components/appLayout.tsx
+++ b/ui-tui/src/components/appLayout.tsx
@@ -135,7 +135,14 @@ const TranscriptPane = memo(function TranscriptPane({
- {row.msg.info && }
+ {row.msg.info && (
+
+ )}
) : row.msg.kind === 'panel' && row.msg.panelData ? (
@@ -146,11 +153,11 @@ const TranscriptPane = memo(function TranscriptPane({
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
msg={row.msg}
- prev={prevRenderedMsg(
- i => transcript.virtualRows[i]?.msg,
- row.index,
- { commandOverride: ui.detailsModeCommandOverride, detailsMode: ui.detailsMode, sections: ui.sections }
- )}
+ prev={prevRenderedMsg(i => transcript.virtualRows[i]?.msg, row.index, {
+ commandOverride: ui.detailsModeCommandOverride,
+ detailsMode: ui.detailsMode,
+ sections: ui.sections
+ })}
sections={ui.sections}
t={ui.theme}
/>
@@ -196,7 +203,15 @@ const ComposerPane = memo(function ComposerPane({
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
- const promptText = composerPromptText(ui.theme.brand.prompt, ui.info?.profile_name, sh, TERMUX_TUI_MODE, composer.cols)
+
+ const promptText = composerPromptText(
+ ui.theme.brand.prompt,
+ ui.info?.profile_name,
+ sh,
+ TERMUX_TUI_MODE,
+ composer.cols
+ )
+
const promptWidth = composerPromptWidth(promptText)
const promptBlank = ' '.repeat(promptWidth)
const inputColumns = stableComposerColumns(composer.cols, promptWidth, TERMUX_TUI_MODE)
diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx
index e2023ab7c2a..136b97db90c 100644
--- a/ui-tui/src/components/branding.tsx
+++ b/ui-tui/src/components/branding.tsx
@@ -50,8 +50,7 @@ const TAG_TINY = 'Nous Research'
const HIDE_BELOW = 34
const COMPACT_FROM = 58
-const clip = (s: string, w: number) =>
- w <= 0 ? '' : s.length > w ? `${s.slice(0, Math.max(0, w - 1))}…` : s
+const clip = (s: string, w: number) => (w <= 0 ? '' : s.length > w ? `${s.slice(0, Math.max(0, w - 1))}…` : s)
const centerIn = (s: string, w: number) => {
const f = clip(s, w)
@@ -75,7 +74,9 @@ function CompactBanner({ cols, t }: { cols: number; t: Theme }) {
return (
- {ruleIn(t.brand.name, w)}
+
+ {ruleIn(t.brand.name, w)}
+
{centerIn(TAG_FULL, w)}
{'─'.repeat(w)}
@@ -113,8 +114,12 @@ export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) {
return (
- {t.brand.icon} {name}
- {t.brand.icon} {tag}
+
+ {t.brand.icon} {name}
+
+
+ {t.brand.icon} {tag}
+
)
}
@@ -142,12 +147,8 @@ function CollapseToggle({
{title}
- {typeof count === 'number' ? (
- ({count})
- ) : null}
- {suffix ? (
- {suffix}
- ) : null}
+ {typeof count === 'number' ? ({count}) : null}
+ {suffix ? {suffix} : null}
)
}
@@ -212,9 +213,7 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
{truncLine(strip(k) + ': ', vs)}
))}
- {overflow > 0 && (
- (and {overflow} more categories…)
- )}
+ {overflow > 0 && (and {overflow} more categories…)}
>
)
}
@@ -241,9 +240,7 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
{truncLine(strip(k) + ': ', vs)}
))}
- {overflow > 0 && (
- (and {overflow} more toolsets…)
- )}
+ {overflow > 0 && (and {overflow} more toolsets…)}
>
)
}
@@ -282,11 +279,7 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
return No system prompt loaded.
}
- return (
-
- {info.system_prompt}
-
- )
+ return {info.system_prompt}
}
return (
@@ -345,12 +338,7 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
{/* ── Tools (expanded by default) ── */}
- setToolsOpen(v => !v)}
- open={toolsOpen}
- t={t}
- title="Available Tools"
- />
+ setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools" />
{toolsOpen && toolsBody()}
@@ -360,7 +348,9 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
count={skillsTotal}
onToggle={() => setSkillsOpen(v => !v)}
open={skillsOpen}
- suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined}
+ suffix={
+ skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined
+ }
t={t}
title="Available Skills"
/>
diff --git a/ui-tui/src/components/helpHint.tsx b/ui-tui/src/components/helpHint.tsx
index 89049ce14a6..997e093b53b 100644
--- a/ui-tui/src/components/helpHint.tsx
+++ b/ui-tui/src/components/helpHint.tsx
@@ -15,10 +15,7 @@ const COMMON_COMMANDS: [string, string][] = [
const HOTKEY_PREVIEW = HOTKEYS.slice(0, 8)
export function HelpHint({ t }: { t: Theme }) {
- const labelW = Math.max(
- ...COMMON_COMMANDS.map(([k]) => k.length),
- ...HOTKEY_PREVIEW.map(([k]) => k.length)
- )
+ const labelW = Math.max(...COMMON_COMMANDS.map(([k]) => k.length), ...HOTKEY_PREVIEW.map(([k]) => k.length))
const pad = (s: string) => s + ' '.repeat(Math.max(0, labelW - s.length + 2))
@@ -37,9 +34,7 @@ export function HelpHint({ t }: { t: Theme }) {
? quick help
-
- {' · type /help for the full panel · backspace to dismiss'}
-
+ {' · type /help for the full panel · backspace to dismiss'}
diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx
index 3e48c82b0c7..fb7fafd73c5 100644
--- a/ui-tui/src/components/markdown.tsx
+++ b/ui-tui/src/components/markdown.tsx
@@ -213,7 +213,9 @@ const TABLE_PADDING_LEFT = 2 // paddingLeft={2} on the outer
const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
// Guard: empty table
- if (rows.length === 0 || rows[0]!.length === 0) return null
+ if (rows.length === 0 || rows[0]!.length === 0) {
+ return null
+ }
const cellDisplayWidth = (raw: string) => stringWidth(stripInlineMarkup(raw))
@@ -221,7 +223,11 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
const minCellWidth = (raw: string) => {
const text = stripInlineMarkup(raw)
const words = text.split(/\s+/).filter(w => w.length > 0)
- if (words.length === 0) return MIN_COL_WIDTH
+
+ if (words.length === 0) {
+ return MIN_COL_WIDTH
+ }
+
return Math.max(...words.map(w => stringWidth(w)), MIN_COL_WIDTH)
}
@@ -229,7 +235,10 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
// Normalize ragged rows: ensure every row has exactly numCols cells
const normalizedRows = rows.map(row => {
- if (row.length >= numCols) return row.slice(0, numCols)
+ if (row.length >= numCols) {
+ return row.slice(0, numCols)
+ }
+
return [...row, ...Array(numCols - row.length).fill('')]
})
@@ -247,6 +256,7 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
// transcriptBodyWidth (source of cols) subtracts message gutter + scrollbar,
// but NOT this table's paddingLeft — we subtract it here.
const gapOverhead = (numCols - 1) * COL_GAP
+
const availableWidth = cols
? Math.max(cols - TABLE_PADDING_LEFT - gapOverhead - SAFETY_MARGIN, numCols * MIN_COL_WIDTH)
: Infinity
@@ -266,19 +276,23 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
const extraSpace = availableWidth - totalMin
const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!)
const totalOverflow = overflows.reduce((a, b) => a + b, 0)
+
if (totalOverflow === 0) {
columnWidths = [...minWidths]
} else {
- const rawAlloc = minWidths.map((min, i) =>
- min + (overflows[i]! / totalOverflow) * extraSpace
- )
+ const rawAlloc = minWidths.map((min, i) => min + (overflows[i]! / totalOverflow) * extraSpace)
+
columnWidths = rawAlloc.map(v => Math.floor(v))
// Distribute rounding remainders to columns with largest fractional part
let remainder = availableWidth - columnWidths.reduce((a, b) => a + b, 0)
- const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) }))
- .sort((a, b) => b.frac - a.frac)
+
+ const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) })).sort((a, b) => b.frac - a.frac)
+
for (const { i } of fracs) {
- if (remainder <= 0) break
+ if (remainder <= 0) {
+ break
+ }
+
columnWidths[i]!++
remainder--
}
@@ -292,31 +306,40 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
const rawAlloc = minWidths.map(w => w * scaleFactor)
columnWidths = rawAlloc.map(v => Math.max(Math.floor(v), MIN_COL_WIDTH))
let remainder = availableWidth - columnWidths.reduce((a, b) => a + b, 0)
- const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) }))
- .sort((a, b) => b.frac - a.frac)
+
+ const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) })).sort((a, b) => b.frac - a.frac)
+
for (const { i } of fracs) {
- if (remainder <= 0) break
+ if (remainder <= 0) {
+ break
+ }
+
columnWidths[i]!++
remainder--
}
}
// Grapheme-safe hard-break: prefer Intl.Segmenter, fall back to code-point split
- const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
- ? new (Intl as any).Segmenter(undefined, { granularity: 'grapheme' })
- : null
+ const segmenter =
+ typeof Intl !== 'undefined' && 'Segmenter' in Intl
+ ? new (Intl as any).Segmenter(undefined, { granularity: 'grapheme' })
+ : null
const graphemes = (s: string): string[] =>
- segmenter
- ? [...segmenter.segment(s)].map((seg: { segment: string }) => seg.segment)
- : [...s]
+ segmenter ? [...segmenter.segment(s)].map((seg: { segment: string }) => seg.segment) : [...s]
// Word-wrap plain text to fit within `width` display columns.
// Operates on stripped text for correct width measurement.
const wrapCell = (raw: string, width: number, hard: boolean): string[] => {
const text = stripInlineMarkup(raw)
- if (width <= 0) return [text]
- if (stringWidth(text) <= width) return [text]
+
+ if (width <= 0) {
+ return [text]
+ }
+
+ if (stringWidth(text) <= width) {
+ return [text]
+ }
const words = text.split(/\s+/).filter(w => w.length > 0)
const lines: string[] = []
@@ -325,15 +348,18 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
for (const word of words) {
const w = stringWidth(word)
+
if (currentWidth === 0) {
if (hard && w > width) {
for (const ch of graphemes(word)) {
const cw = stringWidth(ch)
+
if (currentWidth + cw > width && current) {
lines.push(current)
current = ''
currentWidth = 0
}
+
current += ch
currentWidth += cw
}
@@ -350,7 +376,11 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
currentWidth = w
}
}
- if (current) lines.push(current)
+
+ if (current) {
+ lines.push(current)
+ }
+
return lines.length > 0 ? lines : ['']
}
@@ -363,26 +393,27 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
// See free-code/src/components/MarkdownTable.tsx L44-L62 for approach.
if (!needsWrap) {
const buildRowString = (row: string[]): string =>
- row.map((cell, ci) => {
- const text = stripInlineMarkup(cell)
- const pad = ' '.repeat(Math.max(0, columnWidths[ci]! - stringWidth(text)))
- const gap = ci < numCols - 1 ? ' ' : ''
- return text + pad + gap
- }).join('')
+ row
+ .map((cell, ci) => {
+ const text = stripInlineMarkup(cell)
+ const pad = ' '.repeat(Math.max(0, columnWidths[ci]! - stringWidth(text)))
+ const gap = ci < numCols - 1 ? ' ' : ''
+
+ return text + pad + gap
+ })
+ .join('')
return (
{normalizedRows.map((row, ri) => (
-
+
{buildRowString(row)}
{ri === 0 && normalizedRows.length > 1 ? (
- {sep}
+
+ {sep}
+
) : null}
))}
@@ -394,23 +425,29 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
type LineEntry = { text: string; kind: 'header' | 'separator' | 'body' }
const buildRowLines = (row: string[]): string[] => {
- const cellLines = row.map((cell, ci) =>
- wrapCell(cell, columnWidths[ci]!, isHard)
- )
+ const cellLines = row.map((cell, ci) => wrapCell(cell, columnWidths[ci]!, isHard))
+
const maxLines = Math.max(...cellLines.map(l => l.length), 1)
const result: string[] = []
+
for (let li = 0; li < maxLines; li++) {
let line = ''
+
for (let ci = 0; ci < numCols; ci++) {
const cl = cellLines[ci] ?? ['']
const cellText = li < cl.length ? cl[li]! : ''
const pad = ' '.repeat(Math.max(0, columnWidths[ci]! - stringWidth(cellText)))
line += cellText + pad
- if (ci < numCols - 1) line += ' '
+
+ if (ci < numCols - 1) {
+ line += ' '
+ }
}
+
result.push(line)
}
+
return result
}
@@ -418,10 +455,14 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
const allEntries: LineEntry[] = []
let tallestBodyRow = 0
normalizedRows.forEach((row, ri) => {
- const kind = ri === 0 ? 'header' as const : 'body' as const
+ const kind = ri === 0 ? ('header' as const) : ('body' as const)
const rowLines = buildRowLines(row)
rowLines.forEach(text => allEntries.push({ text, kind }))
- if (ri > 0) tallestBodyRow = Math.max(tallestBodyRow, rowLines.length)
+
+ if (ri > 0) {
+ tallestBodyRow = Math.max(tallestBodyRow, rowLines.length)
+ }
+
if (ri === 0 && normalizedRows.length > 1) {
allEntries.push({ text: sep, kind: 'separator' })
}
@@ -457,15 +498,20 @@ const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => {
{dataRows.map((row, ri) => (
{ri > 0 ? (
- {'─'.repeat(sepWidth)}
+
+ {'─'.repeat(sepWidth)}
+
) : null}
{headers.map((header, ci) => {
const cell = row[ci] ?? ''
const label = stripInlineMarkup(header) || `Col ${ci + 1}`
+
return (
- {label}:
- {' '}{stripInlineMarkup(cell)}
+
+ {label}:
+ {' '}
+ {stripInlineMarkup(cell)}
)
})}
diff --git a/ui-tui/src/components/petSprite.tsx b/ui-tui/src/components/petSprite.tsx
index 5a17f6337d4..dcf18e40573 100644
--- a/ui-tui/src/components/petSprite.tsx
+++ b/ui-tui/src/components/petSprite.tsx
@@ -10,7 +10,13 @@ const UPPER_HALF = '▀'
const LOWER_HALF = '▄'
const hex = (r: number, g: number, b: number) =>
- `#${[r, g, b].map(v => Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, '0')).join('')}`
+ `#${[r, g, b]
+ .map(v =>
+ Math.max(0, Math.min(255, v | 0))
+ .toString(16)
+ .padStart(2, '0')
+ )
+ .join('')}`
/**
* Renders one petdex frame as truecolor half-blocks using native Ink color
@@ -70,13 +76,7 @@ export const PetSprite = memo(function PetSprite({ grid }: { grid: PetGrid }) {
* cells. Truecolor-only — the color must reach the terminal verbatim for the
* id to decode, which Ghostty/kitty support.
*/
-export const PetKitty = memo(function PetKitty({
- color,
- placeholder
-}: {
- color: string
- placeholder: string[]
-}) {
+export const PetKitty = memo(function PetKitty({ color, placeholder }: { color: string; placeholder: string[] }) {
if (!placeholder.length) {
return null
}
diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx
index 3d796b23983..acac12eef18 100644
--- a/ui-tui/src/components/prompts.tsx
+++ b/ui-tui/src/components/prompts.tsx
@@ -84,7 +84,11 @@ export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptPr
// tail (mirrors the CLI approval panel fix — the full command must be
// reviewable before approving). Border + paddingX + inner padding ≈ 8 cols.
const innerWidth = Math.max(20, cols - 8)
- const rawLines = req.command.split('\n').flatMap(line => wrapAnsi(line, innerWidth, { hard: true, trim: false }).split('\n'))
+
+ const rawLines = req.command
+ .split('\n')
+ .flatMap(line => wrapAnsi(line, innerWidth, { hard: true, trim: false }).split('\n'))
+
const shown = rawLines.slice(0, CMD_PREVIEW_LINES)
const overflow = rawLines.length - shown.length
@@ -119,9 +123,7 @@ export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptPr
))}
-
- ↑/↓ select · Enter confirm · 1-{opts.length} quick pick · Esc/Ctrl+C deny
-
+ ↑/↓ select · Enter confirm · 1-{opts.length} quick pick · Esc/Ctrl+C deny
)
}
diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx
index deb22914695..9cbebd416f4 100644
--- a/ui-tui/src/components/textInput.tsx
+++ b/ui-tui/src/components/textInput.tsx
@@ -24,7 +24,9 @@ type InkExt = typeof Ink & {
}
const ink = Ink as unknown as InkExt
-const { Box, Text, useStdin, useInput, useStdout, stringWidth, useCursorAdvance, useDeclaredCursor, useTerminalFocus } = ink
+
+const { Box, Text, useStdin, useInput, useStdout, stringWidth, useCursorAdvance, useDeclaredCursor, useTerminalFocus } =
+ ink
const ESC = '\x1b'
const INV = `${ESC}[7m`
@@ -371,6 +373,7 @@ export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env):
// no reported drift, so widening to screen would disable the optimization for
// those users with no evidence of a bug.
const term = (env.TERM ?? '').trim().toLowerCase()
+
if ((env.TMUX ?? '').trim().length > 0 || term === 'tmux' || term.startsWith('tmux-')) {
return false
}
@@ -379,7 +382,9 @@ export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env):
// stale paints at soft-wrap boundaries on tall/narrow viewports. Keep this
// off by default in Termux mode; allow explicit opt-in for local debugging.
if (isTermuxTuiMode(env)) {
- const override = String(env.HERMES_TUI_TERMUX_FAST_ECHO ?? '').trim().toLowerCase()
+ const override = String(env.HERMES_TUI_TERMUX_FAST_ECHO ?? '')
+ .trim()
+ .toLowerCase()
if (override) {
return /^(?:1|true|yes|on)$/i.test(override)
@@ -664,7 +669,8 @@ export function TextInput({
}, FRAME_BATCH_MS)
}
- const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY
+ const canFastEchoBase = () =>
+ supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY
const canFastAppend = (current: string, cursor: number, text: string) =>
canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current)
@@ -1007,7 +1013,9 @@ export function TextInput({
const actionDeleteWord = (mod && inp === 'w') || isMacActionFallback(k, inp, 'w')
const range = selRange()
const delFwd = k.delete || fwdDel.current
- const isPrintableInput = (event.keypress.isPasted || inp.length > 0) && PRINTABLE.test(inp.replace(BRACKET_PASTE, ''))
+
+ const isPrintableInput =
+ (event.keypress.isPasted || inp.length > 0) && PRINTABLE.test(inp.replace(BRACKET_PASTE, ''))
if (!isPrintableInput) {
flushKeyBurst()
@@ -1305,9 +1313,7 @@ interface TextInputProps {
voiceRecordKey?: ParsedVoiceRecordKey
}
-export type RightClickDecision =
- | { action: 'copy'; text: string }
- | { action: 'paste' }
+export type RightClickDecision = { action: 'copy'; text: string } | { action: 'paste' }
/**
* Decide what right-click should do on the composer:
diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts
index 843512ed76a..426e6459ca7 100644
--- a/ui-tui/src/config/env.ts
+++ b/ui-tui/src/config/env.ts
@@ -45,8 +45,7 @@ export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
const mouseTrackingOverride = parseToggle(process.env.HERMES_TUI_MOUSE_TRACKING)
const mouseTrackingDisabledLegacy = truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
-const resolvedBootMouseEnabled =
- mouseTrackingOverride ?? (TERMUX_TUI_MODE ? false : !mouseTrackingDisabledLegacy)
+const resolvedBootMouseEnabled = mouseTrackingOverride ?? (TERMUX_TUI_MODE ? false : !mouseTrackingDisabledLegacy)
export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all' : 'off'
diff --git a/ui-tui/src/domain/blockLayout.ts b/ui-tui/src/domain/blockLayout.ts
index 1fad0224617..36c511e4c4d 100644
--- a/ui-tui/src/domain/blockLayout.ts
+++ b/ui-tui/src/domain/blockLayout.ts
@@ -22,12 +22,16 @@ export type BlockGroup = 'diff' | 'intro' | 'model' | 'note' | 'slash' | 'trail'
export const messageGroup = (msg: Pick): BlockGroup => {
switch (msg.kind) {
case 'intro':
+
case 'panel':
return 'intro'
+
case 'slash':
return 'slash'
+
case 'diff':
return 'diff'
+
case 'trail':
return 'trail'
}
@@ -65,10 +69,7 @@ const PAINTS_TRAILING_GAP: ReadonlySet = new Set(['diff', 'user'])
* assistant block therefore computes the same gap while it streams as the
* settled segment does once it flushes, so the live area never jumps.
*/
-export const hasLeadGap = (
- prev: Pick | undefined,
- cur: Pick
-): boolean => {
+export const hasLeadGap = (prev: Pick | undefined, cur: Pick): boolean => {
const group = messageGroup(cur)
if (SELF_SPACED.has(group)) {
diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx
index de60d966760..f25be8091bd 100644
--- a/ui-tui/src/entry.tsx
+++ b/ui-tui/src/entry.tsx
@@ -89,9 +89,13 @@ const stopMemoryMonitor = startMemoryMonitor({
// process.exit(137) closes the child's stdin → the gateway logs a clean
// EOF, NOT SIGTERM. Recording it here is the only way a crash report can
// attribute a death to Node OOM rather than a signal-driven kill.
- recordParentLifecycle(`memory-critical process.exit(137) heap=${formatBytes(snap.heapUsed)} rss=${formatBytes(snap.rss)} dump=${dump?.heapPath ?? 'failed'}`)
+ recordParentLifecycle(
+ `memory-critical process.exit(137) heap=${formatBytes(snap.heapUsed)} rss=${formatBytes(snap.rss)} dump=${dump?.heapPath ?? 'failed'}`
+ )
resetTerminalModes()
- process.stderr.write(`hermes-tui lifecycle: memory critical exit heap=${formatBytes(snap.heapUsed)} rss=${formatBytes(snap.rss)}\n`)
+ process.stderr.write(
+ `hermes-tui lifecycle: memory critical exit heap=${formatBytes(snap.heapUsed)} rss=${formatBytes(snap.rss)}\n`
+ )
process.stderr.write(dumpNotice(snap, dump))
process.stderr.write('hermes-tui: exiting to avoid OOM; restart to recover\n')
process.exit(137)
@@ -102,7 +106,9 @@ const stopMemoryMonitor = startMemoryMonitor({
// so the only trace was a bare gateway `stdin EOF`. Persist a breadcrumb +
// stderr line so the next such death is attributable instead of silent.
onWarn: snap => {
- recordParentLifecycle(`memory-warning fast heap growth heap=${formatBytes(snap.heapUsed)} rss=${formatBytes(snap.rss)}`)
+ recordParentLifecycle(
+ `memory-warning fast heap growth heap=${formatBytes(snap.heapUsed)} rss=${formatBytes(snap.rss)}`
+ )
process.stderr.write(
`hermes-tui: heap climbing fast (${formatBytes(snap.heapUsed)}) — a large tool output or long session may be straining memory\n`
)
diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts
index a2008374acf..5759e0bd0ee 100644
--- a/ui-tui/src/gatewayClient.ts
+++ b/ui-tui/src/gatewayClient.ts
@@ -410,7 +410,9 @@ export class GatewayClient extends EventEmitter {
return
}
- this.lifecycle(`[lifecycle] child exit ${describeChild(ownedProc)} code=${code ?? 'null'} signal=${signal ?? 'null'}`)
+ this.lifecycle(
+ `[lifecycle] child exit ${describeChild(ownedProc)} code=${code ?? 'null'} signal=${signal ?? 'null'}`
+ )
this.handleTransportExit(code)
})
}
@@ -741,7 +743,9 @@ export class GatewayClient extends EventEmitter {
const proc = this.proc
const killed = proc?.kill()
- this.lifecycle(`[lifecycle] GatewayClient.kill reason=${reason} ${describeChild(proc)} killResult=${killed ?? 'none'}`)
+ this.lifecycle(
+ `[lifecycle] GatewayClient.kill reason=${reason} ${describeChild(proc)} killResult=${killed ?? 'none'}`
+ )
this.closeGatewaySocket()
this.closeSidecarSocket()
this.clearReadyTimer()
diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts
index 1e252e706a3..425434353fa 100644
--- a/ui-tui/src/gatewayTypes.ts
+++ b/ui-tui/src/gatewayTypes.ts
@@ -188,7 +188,12 @@ export interface ConfigVoiceConfig {
}
export interface ConfigFullResponse {
- config?: { display?: ConfigDisplayConfig; voice?: ConfigVoiceConfig; paste_collapse_threshold?: number; paste_collapse_char_threshold?: number }
+ config?: {
+ display?: ConfigDisplayConfig
+ voice?: ConfigVoiceConfig
+ paste_collapse_threshold?: number
+ paste_collapse_char_threshold?: number
+ }
}
export interface ConfigMtimeResponse {
@@ -648,7 +653,11 @@ export type GatewayEvent =
type: 'gateway.start_timeout'
}
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
- | { payload?: { text?: string; verbose?: boolean }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
+ | {
+ payload?: { text?: string; verbose?: boolean }
+ session_id?: string
+ type: 'reasoning.delta' | 'reasoning.available'
+ }
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
| { payload: { name?: string }; session_id?: string; type: 'tool.generating' }
| {
diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts
index d32b0de647c..b3e31696ab6 100644
--- a/ui-tui/src/hooks/useCompletion.ts
+++ b/ui-tui/src/hooks/useCompletion.ts
@@ -65,6 +65,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
ref.current = input
const request = completionRequestForInput(input)
+
if (!request) {
clear()
diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts
index 4a5387ae2d2..499019176f7 100644
--- a/ui-tui/src/lib/clipboard.ts
+++ b/ui-tui/src/lib/clipboard.ts
@@ -103,10 +103,7 @@ function _powershellWriteScript(b64: string): string {
return `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')))`
}
-function writeClipboardCommands(
- platform: NodeJS.Platform,
- env: NodeJS.ProcessEnv
-): WriteCmd[] {
+function writeClipboardCommands(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): WriteCmd[] {
if (platform === 'darwin') {
return [{ cmd: 'pbcopy', args: [], stdin: true }]
}
@@ -157,14 +154,23 @@ export async function writeClipboardText(
try {
const ok = await new Promise(resolve => {
if (cmdEntry.stdin) {
- const child = start(cmdEntry.cmd, [...cmdEntry.args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
+ const child = start(cmdEntry.cmd, [...cmdEntry.args], {
+ stdio: ['pipe', 'ignore', 'ignore'],
+ windowsHide: true
+ })
+
child.once('error', () => resolve(false))
child.once('close', (code: number | null) => resolve(code === 0))
child.stdin?.end(text)
} else {
const b64 = Buffer.from(text, 'utf8').toString('base64')
const script = _powershellWriteScript(b64)
- const child = start(cmdEntry.cmd, [...cmdEntry.args, '-Command', script], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true })
+
+ const child = start(cmdEntry.cmd, [...cmdEntry.args, '-Command', script], {
+ stdio: ['ignore', 'ignore', 'ignore'],
+ windowsHide: true
+ })
+
child.once('error', () => resolve(false))
child.once('close', (code: number | null) => resolve(code === 0))
}
diff --git a/ui-tui/src/lib/externalLink.ts b/ui-tui/src/lib/externalLink.ts
index 67ac2b86832..f0256f5be15 100644
--- a/ui-tui/src/lib/externalLink.ts
+++ b/ui-tui/src/lib/externalLink.ts
@@ -1,4 +1,5 @@
import { isIP } from 'node:net'
+
import { useEffect, useMemo, useState } from 'react'
const titleCache = new Map()
@@ -186,7 +187,12 @@ function isPrivateIpv6(value: string): boolean {
return true
}
- if (normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) {
+ if (
+ normalized.startsWith('fe8') ||
+ normalized.startsWith('fe9') ||
+ normalized.startsWith('fea') ||
+ normalized.startsWith('feb')
+ ) {
return true
}
diff --git a/ui-tui/src/lib/fuzzy.test.ts b/ui-tui/src/lib/fuzzy.test.ts
index 10292c495c4..8edb44a9aab 100644
--- a/ui-tui/src/lib/fuzzy.test.ts
+++ b/ui-tui/src/lib/fuzzy.test.ts
@@ -93,7 +93,13 @@ describe('fuzzyRank', () => {
it('is stable for equal scores (original index tiebreak)', () => {
const items = ['ab', 'ab', 'ab']
- const ranked = fuzzyRank(items.map((v, i) => ({ v, i })), 'ab', x => x.v)
+
+ const ranked = fuzzyRank(
+ items.map((v, i) => ({ v, i })),
+ 'ab',
+ x => x.v
+ )
+
expect(ranked.map(r => r.item.i)).toEqual([0, 1, 2])
})
diff --git a/ui-tui/src/lib/mathUnicode.ts b/ui-tui/src/lib/mathUnicode.ts
index 17af85ee03b..8d042614889 100644
--- a/ui-tui/src/lib/mathUnicode.ts
+++ b/ui-tui/src/lib/mathUnicode.ts
@@ -423,6 +423,7 @@ const SUBSCRIPT: Record = {
// exported `BOX_RE` below.
export const BOX_OPEN = '\u0001'
export const BOX_CLOSE = '\u0002'
+// eslint-disable-next-line no-control-regex -- intentional sentinel control chars
export const BOX_RE = /\u0001([^\u0001\u0002]*)\u0002/g
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -515,6 +516,7 @@ const readBraced = (s: string, start: number): { content: string; end: number }
// should not change the brace counter.
if (c === '\\' && i + 1 < s.length) {
i += 2
+
continue
}
@@ -560,6 +562,7 @@ const replaceBracedCommand = (input: string, command: string, render: (content:
if (after && /[A-Za-z]/.test(after)) {
out += input.slice(i, idx + cmdLen)
i = idx + cmdLen
+
continue
}
@@ -567,13 +570,16 @@ const replaceBracedCommand = (input: string, command: string, render: (content:
let p = idx + cmdLen
- while (input[p] === ' ' || input[p] === '\t') p++
+ while (input[p] === ' ' || input[p] === '\t') {
+ p++
+ }
const arg = readBraced(input, p)
if (!arg) {
out += input.slice(idx, p + 1)
i = p + 1
+
continue
}
@@ -607,6 +613,7 @@ const replaceFracs = (input: string): string => {
if (after && /[A-Za-z]/.test(after)) {
out += input.slice(i, idx + 5)
i = idx + 5
+
continue
}
@@ -614,25 +621,31 @@ const replaceFracs = (input: string): string => {
let p = idx + 5
- while (input[p] === ' ' || input[p] === '\t') p++
+ while (input[p] === ' ' || input[p] === '\t') {
+ p++
+ }
const num = readBraced(input, p)
if (!num) {
out += input.slice(idx, p + 1)
i = p + 1
+
continue
}
p = num.end
- while (input[p] === ' ' || input[p] === '\t') p++
+ while (input[p] === ' ' || input[p] === '\t') {
+ p++
+ }
const den = readBraced(input, p)
if (!den) {
out += input.slice(idx, p + 1)
i = p + 1
+
continue
}
diff --git a/ui-tui/src/lib/memory.test.ts b/ui-tui/src/lib/memory.test.ts
index befcd3d6453..92f177c7ef5 100644
--- a/ui-tui/src/lib/memory.test.ts
+++ b/ui-tui/src/lib/memory.test.ts
@@ -121,11 +121,17 @@ describe('heapdump retention guard (#21767)', () => {
})
afterEach(() => {
- if (savedDir === undefined) {delete process.env.HERMES_HEAPDUMP_DIR}
- else {process.env.HERMES_HEAPDUMP_DIR = savedDir}
+ if (savedDir === undefined) {
+ delete process.env.HERMES_HEAPDUMP_DIR
+ } else {
+ process.env.HERMES_HEAPDUMP_DIR = savedDir
+ }
- if (savedMax === undefined) {delete process.env.HERMES_HEAPDUMP_MAX_BYTES}
- else {process.env.HERMES_HEAPDUMP_MAX_BYTES = savedMax}
+ if (savedMax === undefined) {
+ delete process.env.HERMES_HEAPDUMP_MAX_BYTES
+ } else {
+ process.env.HERMES_HEAPDUMP_MAX_BYTES = savedMax
+ }
rmSync(dir, { force: true, recursive: true })
})
diff --git a/ui-tui/src/lib/memoryMonitor.ts b/ui-tui/src/lib/memoryMonitor.ts
index 1cb25390609..dd20ff27da0 100644
--- a/ui-tui/src/lib/memoryMonitor.ts
+++ b/ui-tui/src/lib/memoryMonitor.ts
@@ -38,6 +38,7 @@ const MB = 1024 ** 2
// thresholds below the warn watermark. Callers may still override explicitly.
function resolveThresholds(criticalBytes?: number, highBytes?: number) {
let limit = 0
+
try {
limit = getHeapStatistics().heap_size_limit || 0
} catch {
@@ -132,12 +133,14 @@ export function startMemoryMonitor({
warned = false
}
}
+
lastHeap = heapUsed
const level: MemoryLevel = heapUsed >= critical ? 'critical' : heapUsed >= high ? 'high' : 'normal'
if (level === 'normal') {
dumped.clear()
+
return
}
@@ -168,6 +171,7 @@ export function startMemoryMonitor({
dumped.add(level)
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
+
const snap: MemorySnapshot = { heapUsed, level, rss }
;(level === 'critical' ? onCritical : onHigh)?.(snap, dump)
diff --git a/ui-tui/src/lib/parentLog.ts b/ui-tui/src/lib/parentLog.ts
index 24f45855239..9af40300ca8 100644
--- a/ui-tui/src/lib/parentLog.ts
+++ b/ui-tui/src/lib/parentLog.ts
@@ -44,7 +44,9 @@ export function recordParentLifecycle(line: string): void {
const oneLine = line.replace(/[\r\n]+/g, ' ↵ ')
const capped =
- oneLine.length > MAX_BREADCRUMB ? `${oneLine.slice(0, MAX_BREADCRUMB)}… [truncated ${oneLine.length} chars]` : oneLine
+ oneLine.length > MAX_BREADCRUMB
+ ? `${oneLine.slice(0, MAX_BREADCRUMB)}… [truncated ${oneLine.length} chars]`
+ : oneLine
mkdirSync(logDir, { recursive: true })
appendFileSync(CRASH_LOG, `[tui-parent] ${new Date().toISOString()} ${capped}\n`)
diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts
index d7d2cc1ff0f..60f6758684c 100644
--- a/ui-tui/src/lib/platform.ts
+++ b/ui-tui/src/lib/platform.ts
@@ -189,22 +189,23 @@ interface RuntimeKeyEvent {
/** Match an ink ``key`` event against a parsed named key. The ink runtime
* sets one boolean per named key; ``space`` is a printable char so it
* arrives as ``ch === ' '`` rather than a dedicated ``key.space`` flag. */
-const _matchesNamedKey = (
- named: VoiceRecordKeyNamed,
- key: RuntimeKeyEvent,
- ch: string
-): boolean => {
+const _matchesNamedKey = (named: VoiceRecordKeyNamed, key: RuntimeKeyEvent, ch: string): boolean => {
switch (named) {
case 'backspace':
return key.backspace === true
+
case 'delete':
return key.delete === true
+
case 'enter':
return key.return === true
+
case 'escape':
return key.escape === true
+
case 'space':
return ch === ' '
+
case 'tab':
return key.tab === true
}
@@ -236,7 +237,10 @@ export const parseVoiceRecordKey = (raw: unknown): ParsedVoiceRecordKey => {
return DEFAULT_VOICE_RECORD_KEY
}
- const parts = lower.split('+').map(p => p.trim()).filter(Boolean)
+ const parts = lower
+ .split('+')
+ .map(p => p.trim())
+ .filter(Boolean)
if (!parts.length) {
return DEFAULT_VOICE_RECORD_KEY
@@ -325,11 +329,10 @@ export const parseVoiceRecordKey = (raw: unknown): ParsedVoiceRecordKey => {
export const formatVoiceRecordKey = (parsed: ParsedVoiceRecordKey): string => {
const modLabel =
parsed.mod === 'super' ? (isMac ? 'Cmd' : 'Super') : parsed.mod[0].toUpperCase() + parsed.mod.slice(1)
+
// Named tokens render in title case (Ctrl+Space, Ctrl+Enter); single
// chars render upper-case to match the existing Ctrl+B convention.
- const keyLabel = parsed.named
- ? parsed.named[0].toUpperCase() + parsed.named.slice(1)
- : parsed.ch.toUpperCase()
+ const keyLabel = parsed.named ? parsed.named[0].toUpperCase() + parsed.named.slice(1) : parsed.ch.toUpperCase()
return `${modLabel}+${keyLabel}`
}
@@ -382,6 +385,7 @@ export const isVoiceToggleKey = (
// require an explicit alt bit for escape chords (Copilot round-7
// follow-up on #19835).
return (key.alt === true || (key.meta && key.escape !== true)) && !key.ctrl && key.super !== true
+
case 'ctrl':
// Require the Ctrl bit AND a clear Alt/Super so a chord like
// Ctrl+Alt+ / Ctrl+Cmd+ doesn't spuriously match
@@ -397,6 +401,7 @@ export const isVoiceToggleKey = (
}
return _isDefaultVoiceKey(configured) && isMac && key.super === true && !key.alt && !key.meta
+
case 'super':
// Require the explicit ``key.super`` bit (kitty-style protocol)
// AND clear Ctrl/Alt/Meta so Ctrl+Cmd+X or Alt+Cmd+X don't
diff --git a/ui-tui/src/lib/prompt.ts b/ui-tui/src/lib/prompt.ts
index 10961b90312..27b30474c02 100644
--- a/ui-tui/src/lib/prompt.ts
+++ b/ui-tui/src/lib/prompt.ts
@@ -20,6 +20,7 @@ export function composerPromptText(
// On very wide panes we can still include profile context. On narrow/mobile
// panes this burns precious columns and increases wrap/clipping risk.
const wideEnoughForProfile = typeof totalCols === 'number' ? totalCols >= 90 : false
+
if (wideEnoughForProfile && profileName && !['default', 'custom'].includes(profileName)) {
return `${profileName} ${basePrompt}`
}
diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts
index 76862f07366..fda9694ddeb 100644
--- a/ui-tui/src/lib/rpc.ts
+++ b/ui-tui/src/lib/rpc.ts
@@ -30,7 +30,7 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul
return {
type: 'send',
message: o.message,
- notice: typeof o.notice === 'string' ? o.notice : undefined,
+ notice: typeof o.notice === 'string' ? o.notice : undefined
}
}
@@ -38,7 +38,7 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul
return {
type: 'prefill',
message: o.message,
- notice: typeof o.notice === 'string' ? o.notice : undefined,
+ notice: typeof o.notice === 'string' ? o.notice : undefined
}
}
diff --git a/ui-tui/src/lib/terminalModes.ts b/ui-tui/src/lib/terminalModes.ts
index 79d6981f273..46712a1d902 100644
--- a/ui-tui/src/lib/terminalModes.ts
+++ b/ui-tui/src/lib/terminalModes.ts
@@ -1,8 +1,8 @@
import { writeSync } from 'node:fs'
export const TERMINAL_MODE_RESET =
- '\x1b[0\'z' + // DEC locator reporting
- '\x1b[0\'{' + // selectable locator events
+ "\x1b[0'z" + // DEC locator reporting
+ "\x1b[0'{" + // selectable locator events
'\x1b[?2029l' + // passive mouse
'\x1b[?1016l' + // SGR-pixels mouse
'\x1b[?1015l' + // urxvt decimal mouse
@@ -31,6 +31,7 @@ export function resetTerminalModes(stream: ResettableStream = process.stdout): b
}
const fd = typeof stream.fd === 'number' ? stream.fd : stream === process.stdout ? 1 : undefined
+
if (fd !== undefined) {
try {
writeSync(fd, TERMINAL_MODE_RESET)
diff --git a/ui-tui/src/lib/termux.ts b/ui-tui/src/lib/termux.ts
index 20328b8e678..492e43cceca 100644
--- a/ui-tui/src/lib/termux.ts
+++ b/ui-tui/src/lib/termux.ts
@@ -19,7 +19,9 @@ export const isTermuxTuiMode = (env: NodeJS.ProcessEnv = process.env): boolean =
return false
}
- const override = String(env.HERMES_TUI_TERMUX_MODE ?? '').trim().toLowerCase()
+ const override = String(env.HERMES_TUI_TERMUX_MODE ?? '')
+ .trim()
+ .toLowerCase()
if (override) {
return truthy(override)
diff --git a/ui-tui/src/lib/text.test.ts b/ui-tui/src/lib/text.test.ts
index ebea2f5b5cc..7117e1f44af 100644
--- a/ui-tui/src/lib/text.test.ts
+++ b/ui-tui/src/lib/text.test.ts
@@ -22,9 +22,13 @@ describe('formatAbandonedClarify', () => {
const out = formatAbandonedClarify('How do you want to scope?', ['Option A', 'Option B', 'Option C'], 'timed out')
expect(out).toBe(
- ['ask How do you want to scope?', ' 1. Option A', ' 2. Option B', ' 3. Option C', ' (timed out — no selection)'].join(
- '\n'
- )
+ [
+ 'ask How do you want to scope?',
+ ' 1. Option A',
+ ' 2. Option B',
+ ' 3. Option C',
+ ' (timed out — no selection)'
+ ].join('\n')
)
})
diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts
index b1e86e36750..dff15e21df5 100644
--- a/ui-tui/src/lib/text.ts
+++ b/ui-tui/src/lib/text.ts
@@ -17,6 +17,7 @@ const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
const ANSI_NON_CSI_ESC_SEQ_RE = new RegExp(`${ESC}(?!\\[|\\]|P|X|\\^|_)[ -/]*[0-~]`, 'g')
const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g')
+// eslint-disable-next-line no-control-regex -- intentionally strips C0/C1 control chars
const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g
const WS_RE = /\s+/g
@@ -240,6 +241,7 @@ export const buildVerboseToolTrailLine = (
const detail = [verboseToolBlock('Args', argsText), verboseToolBlock(error ? 'Error' : 'Result', resultText)]
.filter(Boolean)
.join('\n')
+
const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : ''
return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}`
diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts
index e1760cf86aa..bb470da8923 100644
--- a/ui-tui/src/lib/virtualHeights.ts
+++ b/ui-tui/src/lib/virtualHeights.ts
@@ -122,7 +122,9 @@ export const estimatedMsgHeight = (
const hasVisibleDetails = hasVisibleTools || hasVisibleThinking
if (hasVisibleDetails) {
- h += (hasVisibleTools ? (msg.tools?.length ?? 0) : 0) + (hasVisibleThinking ? wrappedLines(msg.thinking ?? '', bodyWidth) : 0)
+ h +=
+ (hasVisibleTools ? (msg.tools?.length ?? 0) : 0) +
+ (hasVisibleThinking ? wrappedLines(msg.thinking ?? '', bodyWidth) : 0)
if (msg.role === 'assistant' && /\S/.test(msg.text)) {
h += 2
diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts
index 6d7426caed4..8c604df8a0f 100644
--- a/ui-tui/src/theme.ts
+++ b/ui-tui/src/theme.ts
@@ -147,11 +147,7 @@ function rgbToHsl(red: number, green: number, blue: number): [number, number, nu
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)
const hue =
- max === rn
- ? (gn - bn) / delta + (gn < bn ? 6 : 0)
- : max === gn
- ? (bn - rn) / delta + 2
- : (rn - gn) / delta + 4
+ max === rn ? (gn - bn) / delta + (gn < bn ? 6 : 0) : max === gn ? (bn - rn) / delta + 2 : (rn - gn) / delta + 4
return [hue / 6, saturation, lightness]
}
@@ -227,9 +223,10 @@ function normalizeAnsiForeground(color: string): string {
const richAnsi = richEightBitColorNumber(rgb[0], rgb[1], rgb[2])
const richRgb = xtermEightBitRgb(richAnsi)
- const ansi = relativeLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE
- ? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2])
- : richAnsi
+ const ansi =
+ relativeLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE
+ ? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2])
+ : richAnsi
return `ansi256(${ansi})`
}
@@ -537,53 +534,60 @@ export function fromSkin(
const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg
const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg
- return normalizeThemeForAnsiLightTerminal({
- color: {
- primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
- accent,
- border: c('ui_border') ?? c('banner_border') ?? d.color.border,
- text: c('ui_text') ?? c('banner_text') ?? d.color.text,
- muted,
- completionBg,
- completionCurrentBg,
- completionMetaBg,
- completionMetaCurrentBg,
+ return normalizeThemeForAnsiLightTerminal(
+ {
+ color: {
+ primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary,
+ accent,
+ border: c('ui_border') ?? c('banner_border') ?? d.color.border,
+ text: c('ui_text') ?? c('banner_text') ?? d.color.text,
+ muted,
+ completionBg,
+ completionCurrentBg,
+ completionMetaBg,
+ completionMetaCurrentBg,
- label: c('ui_label') ?? d.color.label,
- ok: c('ui_ok') ?? d.color.ok,
- error: c('ui_error') ?? d.color.error,
- warn: c('ui_warn') ?? d.color.warn,
+ label: c('ui_label') ?? d.color.label,
+ ok: c('ui_ok') ?? d.color.ok,
+ error: c('ui_error') ?? d.color.error,
+ warn: c('ui_warn') ?? d.color.warn,
- prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
- sessionLabel: c('session_label') ?? muted,
- sessionBorder: c('session_border') ?? muted,
+ prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt,
+ sessionLabel: c('session_label') ?? muted,
+ sessionBorder: c('session_border') ?? muted,
- statusBg: d.color.statusBg,
- statusFg: d.color.statusFg,
- statusGood: c('ui_ok') ?? d.color.statusGood,
- statusWarn: c('ui_warn') ?? d.color.statusWarn,
- statusBad: d.color.statusBad,
- statusCritical: d.color.statusCritical,
- selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? (hasSkinColors ? completionCurrentBg : d.color.selectionBg),
+ statusBg: d.color.statusBg,
+ statusFg: d.color.statusFg,
+ statusGood: c('ui_ok') ?? d.color.statusGood,
+ statusWarn: c('ui_warn') ?? d.color.statusWarn,
+ statusBad: d.color.statusBad,
+ statusCritical: d.color.statusCritical,
+ selectionBg:
+ c('selection_bg') ??
+ c('completion_menu_current_bg') ??
+ (hasSkinColors ? completionCurrentBg : d.color.selectionBg),
- diffAdded: d.color.diffAdded,
- diffRemoved: d.color.diffRemoved,
- diffAddedWord: d.color.diffAddedWord,
- diffRemovedWord: d.color.diffRemovedWord,
- shellDollar: c('shell_dollar') ?? d.color.shellDollar
+ diffAdded: d.color.diffAdded,
+ diffRemoved: d.color.diffRemoved,
+ diffAddedWord: d.color.diffAddedWord,
+ diffRemovedWord: d.color.diffRemovedWord,
+ shellDollar: c('shell_dollar') ?? d.color.shellDollar
+ },
+
+ brand: {
+ name: branding.agent_name ?? d.brand.name,
+ icon: d.brand.icon,
+ prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt),
+ welcome: branding.welcome ?? d.brand.welcome,
+ goodbye: branding.goodbye ?? d.brand.goodbye,
+ tool: toolPrefix || d.brand.tool,
+ helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader)
+ },
+
+ bannerLogo,
+ bannerHero
},
-
- brand: {
- name: branding.agent_name ?? d.brand.name,
- icon: d.brand.icon,
- prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt),
- welcome: branding.welcome ?? d.brand.welcome,
- goodbye: branding.goodbye ?? d.brand.goodbye,
- tool: toolPrefix || d.brand.tool,
- helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader)
- },
-
- bannerLogo,
- bannerHero
- }, process.env, DEFAULT_LIGHT_MODE)
+ process.env,
+ DEFAULT_LIGHT_MODE
+ )
}