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 ( @@ -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 ( - ) @@ -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 ? ( - @@ -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 && (
- 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! ![peacock](/host/p.png) 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: '![cat](/sandbox/cat.png)', 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([{])?(?=$|[\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 + ) }