Compare commits

...
Author SHA1 Message Date
Codeman maintainer 8fcfdb1e6e feat(tabs): orbit the working ring around a busy session tab's dot
The desktop home rail and the phone overview both draw a spinning
`tab-load-spin` ring around their green dot while a session works; the
tab strip itself only pulsed. Same ring on the tab dot now, so "working"
reads identically on every surface.

Drawn as a ::after border circle rather than a halo: the skin block sets
`box-shadow: none` on .tab-status.busy to keep tab dots quiet and
outranks any plain class rule, and a pseudo-element sidesteps that
without reintroducing the glow. It is absolutely positioned, so it never
widens the tab or shifts the label, and it is disabled under
prefers-reduced-motion.

Phones keep their existing tell (a 9px dot with a glow) and suppress the
ring: a 15px ring inside a 32px tab would sit on top of the tab name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 04:00:05 +02:00
Codeman maintainer 45ad9de89e feat(settings): rebuild App Settings as a rail over one scrolling document
The modal had grown to 8 tabs that wrapped onto two rows on desktop and
became a horizontal scroller on phones, with a "Display" mega-tab holding
11 sections and ~35 controls. Local Echo sat 60% down it, and the model
settings were split across two tabs whose three controls fought each
other (the 1M Opus toggle's own hint said it was "ignored when a Claude
Model is selected above").

Replaced with a left rail that is a TABLE OF CONTENTS over one scrolling
document: every section stays mounted, the rail follows the scroll, and
find-in-page works across the whole thing. Nine sections:

  Terminal & Input (Local Echo is the first row of the first section)
  Appearance, Header & Panels, Models, Agents & CLIs,
  Notifications, Voice, Shortcuts, System

Models are now one page. The picker is a card grid of BASE models with a
single "1M context window" switch; context becomes a property of the
chosen model and composes back into `claudeModel` as `base + [1m]`, which
retires the precedence trap. Thinking effort is a segmented control on
the same page, and the old Models tab (task routing) becomes a collapsed
Advanced block under it.

The 12 header-button toggles and the 8 panel toggles become chip grids,
which is most of the old Display tab reclaimed. Rows now say whether a
setting is per-device or synced, stated once per group.

Phones drop the rail for a sticky jump pill that names the current
section and opens a jump list, move Save into the header (the bottom
action bar cost 60px), and render groups as one inset rounded list with
hairline dividers instead of a stack of bordered cards.

Load and save are untouched: every control keeps its id, so
openAppSettings()/saveAppSettings() work as before. Model cards and the
effort segment are views over hidden <select>s that stay the source of
truth. test/app-settings-structure.test.ts pins that contract, plus the
rail hooks admin-ui.js injects the multi-user Users section into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 03:59:43 +02:00
Codeman maintainer c942bb5dfb chore: version packages 2026-08-10 00:55:13 +02:00
Ark0N f98922063a Merge pull request #256 from Ark0N/feat/readmymind-phase2
Read My Mind phase 2: the predictor and the 🧠 button
2026-08-10 00:54:27 +02:00
13 changed files with 2444 additions and 867 deletions
+16
View File
@@ -1,5 +1,21 @@
# aicodeman
## 1.16.2
### Patch Changes
- Clone a Git repository straight into a case, predict the prompt you were about to type, and point a session at a separate Claude account.
**Clone Repo (#251, proposed by @DodgyBadger in #236)**: Add Case gains a **Clone Repo** tab that clones a repository into `codeman-cases/<name>` and registers it as a normal local case. A live verdict under the URL field answers, while you type, whether the URL is cloneable without credentials, what its default branch is, and which branches and tags exist (`POST /api/cases/clone-preflight` behind `git ls-remote --symref`). The case name fills in from the parsed repo, refs come from the remote as a datalist, shallow clone is optional, and a Brain picker (installed CLIs only) points the Run button at the agent you chose. Starting a session stays opt-in, and the tab hides itself when the server has no `git`.
**Every settings writer now refuses to write through a symlink (from the #251 review, affects existing cases too)**: case contents can be foreign, and a repository can ship `.claude` or `.claude/settings.local.json` as a symlink pointing anywhere on this machine. Since `writeFile` follows links, a scaffold write could land outside the case, up to and including replacing your own `~/.claude/settings.json`. All seven writers that touch a case's `settings.local.json` (`writeHooksConfig`, `ensureCodemanHooks`, `refreshStaleCodemanHooks`, `updateCaseModel`, `updateCaseEnvVars`, `stripCaseEnvKeys`, `applyStatusLineConfig`) now go through one `withSafeSettingsWrite()` gate that runs the symlink check inside the per-path settings lock. A refusal is a warning rather than a throw, so hooks degrade to output-based idle detection instead of failing the operation. If you have deliberately symlinked a case's `.claude` or its `settings.local.json`, Codeman will now decline to write there and say so; replace the link with a real file or directory to get hooks, model and statusLine writes back.
The clone endpoint (`POST /api/cases/clone`) is synchronous by design: no job store, no polling, bounded by `GIT_CLONE_TIMEOUT_MS` (default 5 minutes). Security decisions live in a pure half of `src/git-clone.ts` so each is unit-testable without spawning anything: `<name>::<payload>` transports are refused as a family (any of them dispatches to a `git-remote-<name>` helper, which turns a clone into arbitrary command execution), a leading `-` is refused and `--` precedes every operand, argv arrays are used rather than a shell, URLs carrying credentials are refused, and non-interactive means more than `GIT_TERMINAL_PROMPT=0` (empty `GIT_ASKPASS`/`SSH_ASKPASS`, `SSH_ASKPASS_REQUIRE=never`, empty `DISPLAY`, `GCM_INTERACTIVE=never`, `ssh -oBatchMode=yes`), since with the request held open any one of those left open is a hang instead of an error. Timeouts signal the process group, because `git clone` fans out into `git-remote-https`/`index-pack` and SIGTERM to the parent alone can leave the fetch running. Repository contents beat scaffolding: an existing `CLAUDE.md` is kept, hooks merge into whatever `.claude/settings.local.json` the repo shipped, and a repo shipping its own `.claude/settings*` is reported back as a warning, because those hooks run locally as soon as a session starts.
**Read My Mind phase 2 (#256)**: phase 1 (1.16.1) gave each case an intent profile; this turns it into the feature as pitched. Press 🧠 on a Claude session and Codeman predicts the prompt you were about to type, from your stated goals, your recent prompts in your own voice, the last assistant reply, tool activity, git state, away context, sibling sessions, and any dialog the session is waiting on. The context assembler is pure and budgeted with trust tiers, so user-stated intent outranks observed content and terminal output alone can never justify a suggestion. One shot at opus (`readMyMindModel` overrides), a strict JSON contract, and 1 to 3 suggestions typed continue / verify / redirect. The modal keeps the suggestion editable: Send, Insert (drops it on the composer without Enter), Rethink (rejections feed back into the next attempt), Dismiss. Nothing is ever auto-sent, the click is the boundary. Opt-in via App Settings, Panels (synced, default OFF), desktop header only. Agents get the same verb through the Codeman skill (`POST /api/sessions/:id/readmymind`).
**Per-session `CLAUDE_CONFIG_DIR` (#255, designed and specified by @jordan8037310)**: `schemas.ts` gains an exact-key tier (`ALLOWED_ENV_KEYS`) beside `ALLOWED_ENV_PREFIXES`, admitting `CLAUDE_CONFIG_DIR` so a case can run on a separate Claude subscription (client-billed accounts). Exact match only: other `CLAUDE_*` keys and near misses like `CLAUDE_CONFIG_DIR_EXTRA` stay rejected, blocked keys stay blocked. The key survives `getEnvOverridesForPersist()` because it is a path rather than a secret, and dropping it would silently switch a rebuilt session back to the default account after a reboot. Caveat worth knowing: a relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind go blind for that session unless `projects` is symlinked back into the shared tree.
## 1.16.1
### Patch Changes
+3 -1
View File
@@ -74,7 +74,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.16.1 (must match `package.json`)
**Version**: 1.16.2 (must match `package.json`)
## Project Overview
@@ -260,6 +260,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
**App Settings modal** (`#appSettingsModal`): a left rail that is a **table of contents over ONE scrolling document**, not a tab switcher. Every section stays mounted (`.set-section`, ids `settings-terminal|appearance|layout|models|clis|notifications|voice|shortcuts|system`), the rail follows the scroll, and `switchSettingsTab(id)` keeps its historical name but SCROLLS instead of hiding. Phones swap the rail for the sticky `#appSettingsJump` pill (compact layout at ≤860px in mobile.css). ⚠️ **The load/save contract is `getElementById` by id**: `openAppSettings()`/`saveAppSettings()` read every control by a fixed id, so moving a control between sections is free but renaming or dropping one silently stops it loading or saving. `test/app-settings-structure.test.ts` is the static guard. ⚠️ Model cards (`#appSettingsModelCards`) and the effort segment are **views over hidden `<select>`s** that remain the source of truth; the cards hold the BASE model and the "1M context window" switch composes `base + [1m]` back into `claudeModel`, which is what retires the old "takes precedence over the toggle below" trap. ⚠️ `.modal-tabs`/`.modal-tab-btn`/`.modal-tab-content` are still used by `#sessionOptionsModal` and `#createCaseModal`; the settings rail uses its own `set-*` classes and must not restyle them. ⚠️ `admin-ui.js` injects the multi-user Users entry into `.set-rail-items` + `.set-doc`, so those hooks must survive any restructure.
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.16.1",
"version": "1.16.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.16.1",
"version": "1.16.2",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.16.1",
"version": "1.16.2",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+35 -20
View File
@@ -110,34 +110,49 @@
}
// ── Admin Users panel (injected into the App Settings modal) ──────────────
// The settings modal is a rail (table of contents) over ONE scrolling
// document, so this appends a rail entry plus a real section rather than a
// tab button plus a hidden panel.
function injectUsersTab() {
const modal = document.getElementById('appSettingsModal');
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
const tabs = modal.querySelector('.modal-tabs');
const body = modal.querySelector('.modal-body');
if (!tabs || !body) return;
if (!modal || modal.querySelector('[data-section="settings-users"]')) return;
const rail = modal.querySelector('.set-rail-items');
const body = modal.querySelector('.set-doc');
if (!rail || !body) return;
const btn = document.createElement('button');
btn.className = 'modal-tab-btn';
btn.dataset.tab = 'settings-users';
btn.textContent = 'Users';
tabs.appendChild(btn);
const content = document.createElement('div');
content.className = 'modal-tab-content hidden';
btn.type = 'button';
btn.className = 'set-rail-item';
btn.dataset.section = 'settings-users';
btn.innerHTML =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg><span>Users</span>';
rail.appendChild(btn);
const content = document.createElement('section');
content.className = 'set-section';
content.id = 'settings-users';
content.dataset.label = 'Users';
content.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>Users</strong>
<span>
<button class="btn btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
</span>
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg>
<h2>Users</h2>
</div>
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
<p class="set-section-blurb">Users share the host account; this separates workspaces, it does not sandbox
users from each other. Pair with Docker cases for isolation.</p>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
<div class="set-group">
<div class="set-group-head"><h4>Accounts</h4></div>
<div class="set-group-body">
<div class="set-row">
<div class="set-row-text"><span class="set-row-label">Manage users</span></div>
<div class="set-row-actions">
<button class="btn-toolbar btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn-toolbar btn-sm" id="adminAddUser">+ Add user</button>
</div>
</div>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--text-muted)"></p>
</div>
</div>`;
body.appendChild(content);
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
// Render whenever the entry is used (the shared switchSettingsTab scrolls to it).
btn.addEventListener('click', renderUsers);
content.querySelector('#adminAddUser').onclick = addUserFlow;
content.querySelector('#adminOpenPanel').onclick = openAdminPanel;
+829 -770
View File
File diff suppressed because it is too large Load Diff
+363
View File
@@ -680,6 +680,13 @@ html.mobile-init .file-browser-panel {
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent) !important;
}
/* No orbiting ring on phone tabs (styles.css draws one on desktop/tablet):
the dot is already enlarged to 9px with a glow here, and a 15px ring in a
32px tab would sit on top of the tab name. The glow is the phone's tell. */
.session-tab .tab-status.busy::after {
display: none;
}
/* Truncate tab names more aggressively on mobile */
.session-tab .tab-name {
max-width: 50px;
@@ -3099,3 +3106,359 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
padding: 0.65rem 1rem;
}
}
/* ============================================================================
App Settings, compact layout (<= 860px)
Same single scrolling document as the desktop rail layout; only the
navigation changes. The rail collapses to its search field and #appSettingsJump
takes over as the sticky "where am I / jump elsewhere" control, so a phone
spends its vertical budget on settings instead of on chrome.
Groups render as one inset rounded list with hairline dividers rather than a
stack of separate cards: at 390px the per-card borders were most of the pixels.
============================================================================ */
@media (max-width: 860px) {
#appSettingsModal .modal-content.modal-lg {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
}
#appSettingsModal .set-body {
display: flex;
flex-direction: column;
min-height: 0;
}
/* Rail keeps only its search field, laid out as a bar */
#appSettingsModal .set-rail {
flex-direction: row;
align-items: center;
border-right: 0;
border-bottom: 1px solid var(--border);
background: transparent;
padding: 10px 14px;
overflow: visible;
flex-shrink: 0;
}
#appSettingsModal .set-rail-items,
#appSettingsModal .set-rail-foot {
display: none;
}
#appSettingsModal .set-search {
margin: 0;
flex: 1;
}
#appSettingsModal .set-search input {
padding: 9px 10px 9px 30px;
border-radius: 10px;
}
/* Save moves into the header; the bottom action bar would cost 60px */
#appSettingsModal .set-head-save {
display: inline-flex;
}
#appSettingsModal .set-foot {
display: none;
}
#appSettingsModal .set-doc {
padding: 0 14px 34px;
flex: 1;
}
/* ── jump control ──────────────────────────────────────────────────── */
#appSettingsModal .set-jump {
display: flex;
position: sticky;
top: 0;
z-index: 4;
align-items: center;
gap: 8px;
width: 100%;
margin: 10px 0 2px;
padding: 10px 12px;
border-radius: 11px;
font: inherit;
font-size: 0.82rem;
color: var(--text);
background: rgba(var(--accent-rgb), 0.13);
border: 1px solid rgba(var(--accent-rgb), 0.3);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
cursor: pointer;
}
#appSettingsModal .set-jump-ico {
color: var(--accent);
flex-shrink: 0;
}
#appSettingsModal .set-jump-label {
font-weight: 580;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#appSettingsModal .set-jump-chev {
margin-left: auto;
color: var(--text-muted);
flex-shrink: 0;
transition: transform 0.18s;
}
#appSettingsModal .set-jump[aria-expanded='true'] .set-jump-chev {
transform: rotate(180deg);
}
#appSettingsModal .set-jump-veil {
display: none;
position: fixed;
inset: 0;
z-index: 8;
background: rgba(4, 8, 13, 0.62);
}
#appSettingsModal .set-jump-menu {
display: none;
position: absolute;
left: 14px;
right: 14px;
z-index: 9;
padding: 7px;
border-radius: 16px;
/* Opaque on purpose: --floating-bg is translucent and the settings rows
behind the menu bleed through it. */
background: var(--bg-card);
border: 1px solid var(--control-border);
box-shadow: var(--elevated-shadow);
max-height: 70vh;
overflow-y: auto;
}
#appSettingsModal.jump-open .set-jump-veil,
#appSettingsModal.jump-open .set-jump-menu {
display: block;
}
#appSettingsModal .set-jump-row {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 11px 12px;
border: 0;
border-radius: 11px;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 0.82rem;
text-align: left;
}
#appSettingsModal .set-jump-row svg {
color: var(--text-muted);
flex-shrink: 0;
}
#appSettingsModal .set-jump-row .set-jump-count {
margin-left: auto;
font-size: 0.62rem;
color: var(--text-muted);
}
#appSettingsModal .set-jump-row.active {
background: rgba(var(--accent-rgb), 0.14);
color: var(--text);
font-weight: 570;
}
#appSettingsModal .set-jump-row.active svg {
color: var(--accent);
}
/* ── sections step down: the jump pill already names the current one ── */
#appSettingsModal .set-section {
padding-top: 0;
}
#appSettingsModal .set-section + .set-section {
border-top: 0;
margin-top: 0;
}
#appSettingsModal .set-section-head {
gap: 7px;
margin: 18px 0 2px;
}
#appSettingsModal .set-section-head svg {
padding: 0;
border: 0;
background: none;
color: var(--text-muted);
width: 12px;
height: 12px;
}
#appSettingsModal .set-section-head h2 {
font-size: 0.6rem;
font-weight: 640;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
#appSettingsModal .set-section-head::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
}
#appSettingsModal .set-section-blurb {
display: none;
}
/* ── inset grouped list ────────────────────────────────────────────── */
#appSettingsModal .set-group {
margin-top: 14px;
}
#appSettingsModal .set-group + .set-group {
margin-top: 16px;
}
#appSettingsModal .set-group-head {
margin-bottom: 7px;
padding: 0 3px;
}
#appSettingsModal .set-group-hint {
padding: 0 3px;
}
#appSettingsModal .set-group-body {
gap: 0;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 13px;
overflow: hidden;
}
#appSettingsModal .set-group-body > .set-row {
background: transparent;
border: 0;
border-radius: 0;
padding: 12px 13px;
gap: 12px;
}
#appSettingsModal .set-group-body > .set-row + .set-row {
border-top: 1px solid rgba(255, 255, 255, 0.055);
}
#appSettingsModal .set-group-body > .set-chips,
#appSettingsModal .set-group-body > .set-modelgrid,
#appSettingsModal .set-group-body > .set-minigrid,
#appSettingsModal .set-group-body > #appSettingsShortcutsList {
padding: 12px;
}
#appSettingsModal .set-group-body > .event-type-grid {
padding: 12px;
margin: 0;
}
#appSettingsModal .set-row-label {
font-size: 0.84rem;
}
#appSettingsModal .set-row-desc {
font-size: 0.69rem;
max-width: none;
}
/* Fields go full width under their label instead of fighting for the row */
#appSettingsModal .set-row.has-field {
flex-direction: column;
align-items: stretch;
gap: 9px;
}
#appSettingsModal .set-row.has-field .set-select,
#appSettingsModal .set-row.has-field .set-input {
width: 100%;
min-width: 0;
max-width: none;
box-sizing: border-box;
}
#appSettingsModal .set-row-actions-wide {
width: 100%;
}
#appSettingsModal .set-row-actions-wide .set-input {
flex: 1;
min-width: 0;
}
#appSettingsModal .set-num {
width: 76px;
}
/* Bigger touch targets for the toggles and chips */
#appSettingsModal .switch-sm {
width: 40px;
height: 24px;
}
#appSettingsModal .switch-sm .slider:before {
height: 18px;
width: 18px;
}
#appSettingsModal .switch-sm input:checked + .slider:before {
transform: translateX(16px);
}
#appSettingsModal .set-chip {
font-size: 0.78rem;
padding: 9px 14px;
}
#appSettingsModal .set-modelgrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
#appSettingsModal .set-minigrid {
grid-template-columns: 1fr;
}
#appSettingsModal .set-mini .set-select {
width: 148px;
}
/* One scrollable line beats a ragged two-row wrap for 7 effort levels */
#appSettingsModal .set-segment {
overflow-x: auto;
scrollbar-width: none;
}
#appSettingsModal .set-segment::-webkit-scrollbar {
display: none;
}
#appSettingsModal .set-segment button {
flex: 0 0 auto;
padding: 8px 12px;
}
}
+348 -33
View File
@@ -354,11 +354,8 @@ Object.assign(CodemanApp.prototype, {
// Phone overview home screen: only meaningful under 430px, so the row is
// hidden elsewhere rather than offering a toggle that changes nothing.
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
const phoneOnly = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem');
if (mobileOverviewItem) mobileOverviewItem.style.display = phoneOnly;
const phoneSection = document.getElementById('appSettingsPhoneSection');
if (phoneSection) phoneSection.style.display = phoneOnly;
if (mobileOverviewItem) mobileOverviewItem.style.display = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
// in the template, so an unchecked box and a hidden button stay consistent).
@@ -500,12 +497,15 @@ Object.assign(CodemanApp.prototype, {
// Updates section — show current version, reset transient result/progress UI.
this._initUpdatesSection();
// Reset to first tab and wire up tab switching
this.switchSettingsTab('settings-display');
// Model cards + effort segment are views over the hidden <select>s above,
// so they must be synced AFTER those have been given their stored values.
this._initSettingsNav();
this._syncSettingsChips();
this._syncModelCards();
this._syncEffortSegment();
// Back to the top of the document (one scroll, not a tab reset).
this.switchSettingsTab('settings-terminal');
const modal = document.getElementById('appSettingsModal');
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.onclick = () => this.switchSettingsTab(btn.dataset.tab);
});
modal.classList.add('active');
// Activate focus trap
@@ -514,44 +514,359 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Show the App Settings "Codex CLI" tab only on instances where the codex
* binary actually resolves. Both settings on it (approval bypass, animated
* status effects) are passed to `codex` at launch, so on a box without codex
* the tab is a promise nothing can keep.
* Show the App Settings "Codex" group only on instances where the codex binary
* actually resolves. Both settings in it (approval bypass, animated status
* effects) are passed to `codex` at launch, so on a box without codex the
* group is a promise nothing can keep.
*
* Availability comes from the injected `window.__codemanCliAvailable`, shared
* with the welcome buttons and the run-mode dropdown, so the tab never flickers
* in and back out. Only the tab BUTTON is toggled: the panel already carries
* `.modal-tab-content.hidden` unless it is the selected tab, and
* openAppSettings() always reopens on Display, so an unreachable button is
* enough to keep the panel unreachable.
* with the welcome buttons and the run-mode dropdown, so the group never
* flickers in and back out. The inputs stay in the DOM either way, so a user
* without codex can never silently wipe the codex prefs of an instance that
* has it (openAppSettings/saveAppSettings still read and write them).
*
* Note the inverted default versus the run buttons: an UNKNOWN flag hides this
* tab. Hiding a settings tab costs a user nothing (the values stay in the DOM
* and are still saved), whereas hiding a run button would leave a working
* install with nothing to click.
* group. Hiding it costs a user nothing, whereas hiding a run button would
* leave a working install with nothing to click.
*/
_applyCodexSettingsVisibility() {
const btn = document.querySelector('#appSettingsModal .modal-tab-btn[data-tab="settings-codex"]');
if (btn) btn.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
const group = document.getElementById('appSettingsCodexGroup');
if (group) group.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
},
switchSettingsTab(tabName) {
/**
* Scroll the settings document to a section.
*
* Kept under the historical `switchSettingsTab` name because it is the shared
* entry point: openAppSettings() calls it, and admin-ui.js's injected Users
* entry routes through it too. Sections are never hidden any more — the rail
* is a table of contents over ONE document, so "switching" is a scroll.
*/
switchSettingsTab(sectionId) {
// The Shortcuts list renders lazily so it reflects the CURRENT registry
// (defaults + overrides) every time it is reached.
if (sectionId === 'settings-shortcuts') this.renderShortcutSettingsList?.();
const doc = document.getElementById('appSettingsDoc');
const section = document.getElementById(sectionId);
if (doc && section && typeof section.offsetTop === 'number') {
// On phones the jump pill is sticky at the top of the document, so land
// the section head below it instead of underneath it.
const jump = document.getElementById('appSettingsJump');
const inset = jump && jump.offsetParent ? jump.offsetHeight + 16 : 6;
doc.scrollTop = Math.max(0, section.offsetTop - inset);
}
this._setActiveSettingsSection(sectionId);
},
/** Paint the rail + jump pill for the section currently in view. */
_setActiveSettingsSection(sectionId) {
const modal = document.getElementById('appSettingsModal');
// Toggle active class on tab buttons
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
if (!modal || typeof modal.querySelectorAll !== 'function') return;
let active = null;
modal.querySelectorAll('.set-rail-item').forEach(item => {
const on = item.dataset.section === sectionId;
item.classList.toggle('active', on);
if (on) active = item;
});
// Toggle hidden class on tab content
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
modal.querySelectorAll('.set-jump-row').forEach(row => {
row.classList.toggle('active', row.dataset.section === sectionId);
});
// The Shortcuts tab renders lazily so the list reflects the CURRENT
// registry (defaults + overrides) every time it is opened.
if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.();
const label = document.getElementById('appSettingsJump')?.querySelector('.set-jump-label');
if (label && active) label.textContent = active.textContent.trim();
const ico = document.getElementById('appSettingsJump')?.querySelector('.set-jump-ico');
const src = active?.querySelector('svg');
if (ico && src) ico.innerHTML = src.innerHTML;
},
/**
* Wire the settings navigation once per page: rail clicks, the phone jump
* menu, scroll-spy, live search, chip/card/segment views over the real inputs,
* and the collapsible Advanced group. Idempotent — openAppSettings() calls it
* on every open, and re-registering listeners on every open would multiply
* them across a long-lived tab.
*/
_initSettingsNav() {
const modal = document.getElementById('appSettingsModal');
const doc = document.getElementById('appSettingsDoc');
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
this._buildModelCards();
this._buildEffortSegment();
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
// after the first open, and the menu must not drift from the rail.
this._buildSettingsJumpMenu();
if (modal.dataset.navReady === '1') return;
modal.dataset.navReady = '1';
// Delegated so rail entries injected later (Users) work without rewiring.
modal.querySelector('.set-rail-items')?.addEventListener('click', e => {
const item = e.target.closest?.('.set-rail-item');
if (item?.dataset.section) this.switchSettingsTab(item.dataset.section);
});
document.getElementById('appSettingsJumpMenu')?.addEventListener('click', e => {
const row = e.target.closest?.('.set-jump-row');
if (!row?.dataset.section) return;
this._toggleSettingsJump(false);
this.switchSettingsTab(row.dataset.section);
});
document.getElementById('appSettingsJump')?.addEventListener('click', () => this._toggleSettingsJump());
document.getElementById('appSettingsJumpVeil')?.addEventListener('click', () => this._toggleSettingsJump(false));
// Scroll-spy: the rail follows the document rather than driving it.
doc.addEventListener('scroll', () => {
if (this._settingsSpyQueued) return;
this._settingsSpyQueued = true;
requestAnimationFrame(() => {
this._settingsSpyQueued = false;
const sections = [...doc.querySelectorAll('.set-section')].filter(s => s.offsetParent !== null);
if (!sections.length) return;
let current = sections[0].id;
for (const s of sections) {
if (s.offsetTop - doc.scrollTop <= 140) current = s.id;
}
this._setActiveSettingsSection(current);
});
});
const search = document.getElementById('appSettingsSearch');
search?.addEventListener('input', () => this._filterSettings(search.value));
// Chips are labels wrapping the real checkbox; mirror the checked state onto
// the label so the styling does not depend on :has() support.
modal.querySelectorAll('.set-chip input').forEach(input => {
input.addEventListener('change', () => this._syncSettingsChips());
});
const advHead = modal.querySelector('.set-group-head-toggle');
const advGroup = advHead?.closest('.set-group-advanced');
if (advHead && advGroup) {
const toggle = () => {
const open = advGroup.classList.toggle('open');
advHead.setAttribute('aria-expanded', open ? 'true' : 'false');
};
advHead.addEventListener('click', toggle);
advHead.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
}
document.getElementById('appSettingsOpusContext1m')?.addEventListener('change', () => this._applyModelSelection());
},
/** Phone jump menu, mirrored from the rail so the two can never drift. */
_buildSettingsJumpMenu() {
const modal = document.getElementById('appSettingsModal');
const menu = document.getElementById('appSettingsJumpMenu');
if (!modal || !menu) return;
menu.innerHTML = '';
modal.querySelectorAll('.set-rail-item').forEach(item => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'set-jump-row';
row.dataset.section = item.dataset.section;
row.innerHTML = item.innerHTML;
const section = document.getElementById(item.dataset.section);
const count = section ? section.querySelectorAll('input, select').length : 0;
if (count) {
const n = document.createElement('span');
n.className = 'set-jump-count';
n.textContent = String(count);
row.appendChild(n);
}
menu.appendChild(row);
});
},
_toggleSettingsJump(force) {
const modal = document.getElementById('appSettingsModal');
if (!modal) return;
const open = force === undefined ? !modal.classList.contains('jump-open') : force;
modal.classList.toggle('jump-open', open);
document.getElementById('appSettingsJump')?.setAttribute('aria-expanded', open ? 'true' : 'false');
},
/** Mirror checkbox state onto the chip labels (see _initSettingsNav). */
_syncSettingsChips() {
document.querySelectorAll('#appSettingsModal .set-chip').forEach(chip => {
chip.classList.toggle('is-on', !!chip.querySelector('input')?.checked);
});
},
/**
* Build the model picker cards from the hidden <select>'s own options, so the
* select stays the single source of truth that openAppSettings/saveAppSettings
* read and write by id. The `[1m]` variants are folded away: context width is a
* property of the chosen model (the "1M context window" switch), not a rival
* setting that silently loses to it.
*/
_buildModelCards() {
const select = document.getElementById('appSettingsClaudeModel');
const grid = document.getElementById('appSettingsModelCards');
if (!select || !grid || grid.dataset.built === '1' || !select.options) return;
grid.innerHTML = '';
[...select.options]
.filter(opt => opt.dataset.variant !== '1m')
.forEach(opt => {
const card = document.createElement('button');
card.type = 'button';
card.className = 'set-modelcard';
card.setAttribute('role', 'radio');
card.dataset.value = opt.value;
if (opt.dataset.ctx === '1') card.dataset.ctx = '1';
const top = document.createElement('span');
top.className = 'set-mc-top';
const name = document.createElement('span');
name.className = 'set-mc-name';
name.textContent = opt.textContent;
top.appendChild(name);
const dot = document.createElement('span');
dot.className = 'set-mc-dot';
top.appendChild(dot);
card.appendChild(top);
const meta = document.createElement('span');
meta.className = 'set-mc-meta';
meta.textContent = opt.dataset.meta || '';
card.appendChild(meta);
if (opt.dataset.ctx === '1') {
const ctx = document.createElement('span');
ctx.className = 'set-mc-ctx';
ctx.textContent = '1M capable';
card.appendChild(ctx);
}
card.addEventListener('click', () => {
this._settingsModelBase = opt.value;
this._applyModelSelection();
});
grid.appendChild(card);
});
grid.dataset.built = '1';
},
/** Derive card + context-switch state from the select's stored value. */
_syncModelCards() {
const select = document.getElementById('appSettingsClaudeModel');
if (!select) return;
const value = select.value || '';
this._settingsModelBase = value.endsWith('[1m]') ? value.slice(0, -4) : value;
if (value.endsWith('[1m]')) {
const ctx = document.getElementById('appSettingsOpusContext1m');
if (ctx) ctx.checked = true;
}
this._applyModelSelection();
},
/** Compose card + context switch back into the select's value. */
_applyModelSelection() {
const select = document.getElementById('appSettingsClaudeModel');
const grid = document.getElementById('appSettingsModelCards');
if (!select || !grid) return;
const base = this._settingsModelBase || '';
let capable = false;
grid.querySelectorAll('.set-modelcard').forEach(card => {
const on = card.dataset.value === base;
card.classList.toggle('selected', on);
card.setAttribute('aria-checked', on ? 'true' : 'false');
if (on) capable = card.dataset.ctx === '1';
});
const ctxOn = !!document.getElementById('appSettingsOpusContext1m')?.checked;
select.value = base && capable && ctxOn ? `${base}[1m]` : base;
// A model with no 1M variant makes the switch inert; say so instead of
// leaving a toggle that looks like it does something.
const row = document.getElementById('appSettingsContextRow');
const desc = document.getElementById('appSettingsContextDesc');
const inert = !!base && !capable;
row?.classList.toggle('set-row-disabled', inert);
if (desc) {
desc.textContent = inert
? 'The selected model has no 1M variant.'
: base
? 'Available for Fable 5, Opus and Opus 4.6.'
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
}
},
_buildEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
seg.innerHTML = '';
[...select.options].forEach(opt => {
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute('role', 'radio');
btn.dataset.value = opt.value;
btn.textContent = opt.textContent;
btn.addEventListener('click', () => {
select.value = opt.value;
this._syncEffortSegment();
});
seg.appendChild(btn);
});
seg.dataset.built = '1';
},
_syncEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
if (!select || !seg) return;
seg.querySelectorAll('button').forEach(btn => {
const on = btn.dataset.value === (select.value || '');
btn.classList.toggle('selected', on);
btn.setAttribute('aria-checked', on ? 'true' : 'false');
});
},
/**
* Live filter across every section. Everything stays mounted (that is the
* point of the single-document layout), so a search only hides units that do
* not match, then collapses groups and sections left with nothing visible.
*/
_filterSettings(query) {
const doc = document.getElementById('appSettingsDoc');
if (!doc) return;
const q = (query || '').trim().toLowerCase();
const UNIT = '.set-row, .set-chip, .set-modelgrid, .set-minigrid, .event-type-grid, #appSettingsShortcutsList';
const units = [...doc.querySelectorAll(UNIT)];
let anyVisible = false;
units.forEach(unit => {
if (!q) {
unit.classList.remove('set-hit-hidden');
return;
}
const hay = `${unit.dataset?.search || ''} ${unit.textContent || ''}`.toLowerCase();
const hit = hay.includes(q);
unit.classList.toggle('set-hit-hidden', !hit);
if (hit) anyVisible = true;
});
// A chip wrapper is only empty when every chip inside it is hidden.
doc.querySelectorAll('.set-chips').forEach(wrap => {
const hasVisible = [...wrap.querySelectorAll('.set-chip')].some(c => !c.classList.contains('set-hit-hidden'));
wrap.classList.toggle('set-hit-hidden', !!q && !hasVisible);
});
doc.querySelectorAll('.set-group').forEach(group => {
const hasVisible = [...group.querySelectorAll(UNIT)].some(u => !u.classList.contains('set-hit-hidden'));
group.classList.toggle('set-hit-hidden', !!q && !hasVisible);
// An Advanced group that matches must open, or the hit stays invisible.
if (q && hasVisible) group.classList.add('open');
});
doc.querySelectorAll('.set-section').forEach(section => {
const hasVisible = [...section.querySelectorAll('.set-group')].some(g => !g.classList.contains('set-hit-hidden'));
section.classList.toggle('set-hit-hidden', !!q && !hasVisible);
});
const empty = document.getElementById('appSettingsSearchEmpty');
if (empty) empty.hidden = !q || anyVisible;
if (!q) doc.querySelectorAll('.set-group-advanced').forEach(g => g.classList.remove('open'));
},
closeAppSettings() {
this._toggleSettingsJump(false);
document.getElementById('appSettingsModal').classList.remove('active');
// Deactivate focus trap and restore focus
+709 -5
View File
@@ -1316,11 +1316,31 @@ html[data-line-anim="packet"] .connection-line.line-enter {
.session-tab .tab-status.idle { background: var(--green); }
.session-tab .tab-status.busy {
position: relative;
background: var(--green);
animation: pulse 1.5s infinite;
/* Performance: GPU acceleration for animation */
will-change: opacity;
}
/* Orbiting ring on a WORKING tab: the same `tab-load-spin` circle the desktop
home rail (.home-sessions-dot--working) and the phone overview
(.mobile-overview-dot--working) draw, so "working" reads identically on every
surface. Pseudo-element on purpose — the skin block below sets
`box-shadow: none` on .tab-status.busy to keep tab dots halo-free, and it
outranks any plain class rule here; a ring drawn with a border is untouched by
that and keeps the tabs quiet while still showing motion. Absolutely
positioned, so it never widens the tab or shifts the label. */
.session-tab .tab-status.busy::after {
content: '';
position: absolute;
inset: -3px;
border: 1.5px solid color-mix(in srgb, var(--green) 25%, transparent);
border-top-color: var(--green);
border-radius: 50%;
animation: tab-load-spin 0.7s linear infinite;
pointer-events: none;
}
.session-tab .tab-status.error { background: var(--red); }
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
@@ -5765,11 +5785,6 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
min-height: 0;
}
/* App Settings packs a 2-column toggle grid; give it room so right-column
switches aren't cramped against the edge (other modal-lg modals stay 540). */
#appSettingsModal .modal-content.modal-lg {
max-width: 600px;
}
/* Mobile Case Picker - Base Styles */
.mobile-case-picker-sheet {
@@ -14302,9 +14317,698 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
}
@media (prefers-reduced-motion: reduce) {
.session-tab .tab-status.busy::after,
.home-sessions-row,
.home-sessions-dot,
.home-sessions-dot::after {
animation: none !important;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
App Settings (#appSettingsModal)
A left rail that is a TABLE OF CONTENTS over ONE scrolling document, not a
tab switcher: every section stays mounted, the rail follows the scroll, and
the browser's own find-in-page still works across all of them.
Scoped under #appSettingsModal on purpose. `.modal-tabs`/`.modal-tab-btn`
are shared with #sessionOptionsModal and #createCaseModal, so this file
introduces its own `set-*` classes rather than restyling those.
═══════════════════════════════════════════════════════════════════════════ */
#appSettingsModal .modal-content.modal-lg {
max-width: min(960px, 96vw);
width: min(960px, 96vw);
height: min(700px, 88vh);
max-height: 88vh;
}
#appSettingsModal .set-shell-head {
flex-shrink: 0;
}
#appSettingsModal .set-head-actions {
display: flex;
flex-direction: row-reverse;
align-items: center;
gap: 6px;
margin-left: auto;
}
/* The phone layout drops the bottom action bar and saves from the header
instead; on desktop the footer owns Save, so this stays hidden. */
#appSettingsModal .set-head-save {
display: none;
font: inherit;
font-size: 0.82rem;
font-weight: 620;
letter-spacing: -0.01em;
padding: 8px 15px;
border: 0;
border-radius: 10px;
cursor: pointer;
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
color: var(--accent-ink);
}
#appSettingsModal .set-body {
display: grid;
grid-template-columns: 212px minmax(0, 1fr);
flex: 1;
min-height: 0;
}
#appSettingsModal .set-foot {
margin-top: 0;
flex-shrink: 0;
}
/* ── rail ─────────────────────────────────────────────────────────────── */
#appSettingsModal .set-rail {
display: flex;
flex-direction: column;
padding: 12px 10px;
border-right: 1px solid var(--border);
background: rgba(0, 0, 0, 0.16);
overflow-y: auto;
min-height: 0;
}
#appSettingsModal .set-search {
position: relative;
display: flex;
align-items: center;
margin-bottom: 10px;
flex-shrink: 0;
}
#appSettingsModal .set-search svg {
position: absolute;
left: 9px;
color: var(--text-muted);
pointer-events: none;
}
#appSettingsModal .set-search input {
width: 100%;
box-sizing: border-box;
padding: 7px 9px 7px 28px;
font: inherit;
font-size: 0.75rem;
color: var(--text);
background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border);
border-radius: 8px;
outline: none;
-webkit-appearance: none;
appearance: none;
}
#appSettingsModal .set-search input:focus {
border-color: var(--accent);
}
#appSettingsModal .set-rail-items {
display: flex;
flex-direction: column;
gap: 1px;
}
#appSettingsModal .set-rail-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 0.77rem;
cursor: pointer;
text-align: left;
position: relative;
transition: color 0.15s, background 0.15s;
}
#appSettingsModal .set-rail-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
#appSettingsModal .set-rail-item:hover {
background: var(--control-bg);
color: var(--text);
}
#appSettingsModal .set-rail-item.active {
color: var(--accent);
font-weight: 580;
}
#appSettingsModal .set-rail-item.active svg {
color: var(--accent);
}
#appSettingsModal .set-rail-item.active::before {
content: '';
position: absolute;
left: 2px;
top: 6px;
bottom: 6px;
width: 2px;
border-radius: 2px;
background: var(--accent);
}
#appSettingsModal .set-rail-foot {
margin: auto 0 0;
padding: 10px 10px 2px;
font-size: 0.62rem;
line-height: 1.5;
color: var(--text-muted);
border-top: 1px solid var(--border);
}
/* ── document ─────────────────────────────────────────────────────────── */
#appSettingsModal .set-doc {
overflow-y: auto;
padding: 0 22px 40px;
min-width: 0;
min-height: 0;
position: relative;
}
#appSettingsModal .set-section {
padding-top: 20px;
}
#appSettingsModal .set-section + .set-section {
border-top: 1px solid var(--border);
margin-top: 26px;
}
#appSettingsModal .set-section-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
#appSettingsModal .set-section-head svg {
color: var(--accent);
flex-shrink: 0;
padding: 5px;
border-radius: 8px;
background: rgba(var(--accent-rgb), 0.13);
border: 1px solid rgba(var(--accent-rgb), 0.24);
box-sizing: content-box;
}
#appSettingsModal .set-section-head h2 {
margin: 0;
font-size: 0.95rem;
font-weight: 640;
letter-spacing: -0.015em;
color: var(--text);
}
#appSettingsModal .set-section-blurb {
margin: 0 0 14px 36px;
font-size: 0.69rem;
color: var(--text-muted);
}
#appSettingsModal .set-empty {
margin: 24px 0 0;
font-size: 0.78rem;
color: var(--text-muted);
text-align: center;
}
/* ── groups ───────────────────────────────────────────────────────────── */
#appSettingsModal .set-group + .set-group {
margin-top: 18px;
}
#appSettingsModal .set-group-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
#appSettingsModal .set-group-head h4 {
margin: 0;
font-size: 0.63rem;
font-weight: 640;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
#appSettingsModal .set-group-head::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
order: 9;
}
#appSettingsModal .set-group-head-toggle {
cursor: pointer;
}
#appSettingsModal .set-adv {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.62rem;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 20px;
padding: 2px 8px;
}
#appSettingsModal .set-adv svg {
transition: transform 0.18s;
}
#appSettingsModal .set-group-advanced.open .set-adv svg {
transform: rotate(180deg);
}
#appSettingsModal .set-group-advanced:not(.open) .set-group-body,
#appSettingsModal .set-group-advanced:not(.open) .set-group-hint {
display: none;
}
#appSettingsModal .set-group-hint {
margin: -2px 0 9px;
font-size: 0.68rem;
line-height: 1.45;
color: var(--text-muted);
}
#appSettingsModal .set-group-body {
display: flex;
flex-direction: column;
gap: 5px;
}
/* ── rows ─────────────────────────────────────────────────────────────── */
#appSettingsModal .set-row {
display: flex;
align-items: center;
gap: 14px;
padding: 9px 12px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.028);
border: 1px solid rgba(255, 255, 255, 0.055);
transition: background 0.15s, border-color 0.15s;
}
#appSettingsModal .set-row:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.09);
}
#appSettingsModal .set-row-block {
flex-direction: column;
align-items: stretch;
gap: 9px;
}
#appSettingsModal .set-row-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
#appSettingsModal .set-row-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 0.79rem;
color: var(--text);
letter-spacing: -0.005em;
}
#appSettingsModal .set-row-desc {
font-size: 0.66rem;
line-height: 1.45;
color: var(--text-muted);
max-width: 62ch;
}
#appSettingsModal .set-row-actions {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
#appSettingsModal .set-scope,
#appSettingsModal .set-tag {
font-size: 0.52rem;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 1px 4px;
white-space: nowrap;
}
#appSettingsModal .set-scope {
color: var(--text-muted);
border: 1px solid var(--border);
opacity: 0.8;
}
#appSettingsModal .set-tag {
color: var(--accent);
background: rgba(var(--accent-rgb), 0.13);
border: 1px solid rgba(var(--accent-rgb), 0.25);
}
#appSettingsModal .set-tag-beta {
color: var(--yellow);
background: rgba(240, 194, 90, 0.12);
border-color: rgba(240, 194, 90, 0.28);
}
/* ── controls ─────────────────────────────────────────────────────────── */
#appSettingsModal .set-select,
#appSettingsModal .set-input,
#appSettingsModal .set-num {
font: inherit;
font-size: 0.74rem;
color: var(--text);
background: rgba(0, 0, 0, 0.24);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px 8px;
outline: none;
flex-shrink: 0;
}
#appSettingsModal .set-select:focus,
#appSettingsModal .set-input:focus,
#appSettingsModal .set-num:focus {
border-color: var(--accent);
}
#appSettingsModal .set-select {
min-width: 190px;
max-width: 260px;
}
#appSettingsModal .set-input {
width: 220px;
}
#appSettingsModal .set-num {
width: 62px;
text-align: right;
}
#appSettingsModal .set-unit {
font-size: 0.65rem;
color: var(--text-muted);
}
#appSettingsModal .set-static {
font-size: 0.74rem;
color: var(--text-dim);
}
#appSettingsModal .mono {
font-family: var(--font-mono);
}
#appSettingsModal .set-copy {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
text-decoration: underline;
cursor: pointer;
word-break: break-all;
}
/* The cards and the segmented control are the visible UI; the underlying
<select> stays in the DOM as the single source of truth that
openAppSettings()/saveAppSettings() read and write by id. */
#appSettingsModal .set-field-hidden {
display: none !important;
}
#appSettingsModal .set-note,
#appSettingsModal .set-notes,
#appSettingsModal .set-progress {
font-size: 0.72rem;
line-height: 1.5;
color: var(--text-secondary);
padding: 8px 10px;
border-radius: 8px;
}
#appSettingsModal .set-notes {
max-height: 160px;
overflow: auto;
font-size: 0.7rem;
white-space: pre-wrap;
word-break: break-word;
background: rgba(127, 127, 127, 0.08);
border: 1px solid var(--border);
}
#appSettingsModal .set-progress {
border: 1px solid var(--border);
}
/* ── chips (header buttons, panels, features, levels) ─────────────────── */
#appSettingsModal .set-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
#appSettingsModal .set-chip {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.72rem;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--control-border);
background: rgba(255, 255, 255, 0.028);
color: var(--text-dim);
cursor: pointer;
user-select: none;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
#appSettingsModal .set-chip input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
#appSettingsModal .set-chip::before {
content: '\2713';
font-size: 0.8em;
line-height: 1;
width: 0;
overflow: hidden;
opacity: 0;
transition: width 0.15s, opacity 0.15s;
}
#appSettingsModal .set-chip:hover {
border-color: var(--control-border-hover);
color: var(--text);
}
/* `.is-on` is written by settings-ui.js on load and on change; `:has()` keeps
the state correct for any chip toggled before that listener runs. */
#appSettingsModal .set-chip.is-on,
#appSettingsModal .set-chip:has(input:checked) {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.45);
background: rgba(var(--accent-rgb), 0.12);
}
#appSettingsModal .set-chip.is-on::before,
#appSettingsModal .set-chip:has(input:checked)::before {
width: 0.85em;
opacity: 1;
}
#appSettingsModal .set-chip:focus-within {
outline: 2px solid rgba(var(--accent-rgb), 0.5);
outline-offset: 1px;
}
/* ── model cards ──────────────────────────────────────────────────────── */
#appSettingsModal .set-modelgrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
#appSettingsModal .set-modelcard {
display: flex;
flex-direction: column;
gap: 3px;
text-align: left;
font: inherit;
padding: 9px 11px;
border-radius: 10px;
border: 1px solid var(--control-border);
background: rgba(255, 255, 255, 0.028);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
#appSettingsModal .set-modelcard:hover {
border-color: var(--control-border-hover);
background: rgba(255, 255, 255, 0.05);
}
#appSettingsModal .set-modelcard.selected {
border-color: rgba(var(--accent-rgb), 0.55);
background: rgba(var(--accent-rgb), 0.11);
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.2);
}
#appSettingsModal .set-mc-top {
display: flex;
align-items: center;
gap: 6px;
}
#appSettingsModal .set-mc-name {
font-size: 0.78rem;
color: var(--text);
font-weight: 560;
}
#appSettingsModal .set-mc-dot {
margin-left: auto;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.8);
opacity: 0;
}
#appSettingsModal .set-modelcard.selected .set-mc-dot {
opacity: 1;
}
#appSettingsModal .set-mc-meta {
font-size: 0.64rem;
color: var(--text-muted);
}
#appSettingsModal .set-mc-ctx {
margin-top: 2px;
align-self: flex-start;
font-size: 0.53rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 4px;
}
#appSettingsModal .set-row-disabled {
opacity: 0.45;
}
/* ── segmented control (thinking effort) ──────────────────────────────── */
#appSettingsModal .set-segment {
display: flex;
gap: 3px;
padding: 3px;
background: rgba(0, 0, 0, 0.26);
border: 1px solid var(--border);
border-radius: 9px;
}
#appSettingsModal .set-segment button {
flex: 1;
font: inherit;
font-size: 0.68rem;
padding: 6px 4px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
}
#appSettingsModal .set-segment button:hover {
color: var(--text);
background: var(--control-bg);
}
#appSettingsModal .set-segment button.selected {
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
color: var(--accent-ink);
font-weight: 620;
}
/* ── task-routing 2x2 ─────────────────────────────────────────────────── */
#appSettingsModal .set-minigrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
}
#appSettingsModal .set-mini {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 10px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.028);
border: 1px solid rgba(255, 255, 255, 0.055);
}
#appSettingsModal .set-mini-label {
font-size: 0.74rem;
color: var(--text-dim);
}
#appSettingsModal .set-mini .set-select {
min-width: 0;
width: 128px;
}
/* Search hides non-matching units; everything stays mounted underneath. */
#appSettingsModal .set-hit-hidden {
display: none !important;
}
/* ── phone jump control: desktop hides it entirely ────────────────────── */
#appSettingsModal .set-jump,
#appSettingsModal .set-jump-menu,
#appSettingsModal .set-jump-veil {
display: none;
}
/* ── injected sections (admin Users panel) ────────────────────────────── */
#appSettingsModal .set-section .form-hint {
display: block;
margin: 0 0 10px;
}
+7 -6
View File
@@ -28,7 +28,7 @@ async function bootWith(me: Record<string, unknown>) {
const dom = new JSDOM(
`<!doctype html><body>
<button id="adminPanelBtn" class="btn-admin-panel btn-admin-panel--hidden"></button>
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
<div class="modal" id="appSettingsModal"><nav class="set-rail"><div class="set-rail-items"></div></nav><div class="set-doc" id="appSettingsDoc"></div></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'outside-only' }
);
@@ -45,22 +45,23 @@ async function bootWith(me: Record<string, unknown>) {
}
describe('admin-ui boot', () => {
it('exposes the identity and injects the Users tab for a multi-user admin', async () => {
it('exposes the identity and injects the Users section for a multi-user admin', async () => {
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
expect(win.__codemanUser).toMatchObject({ username: 'root', role: 'admin', multiUser: true });
const btn = win.document.querySelector('[data-tab="settings-users"]');
// The settings modal is a rail over one document: a rail entry, not a tab.
const btn = win.document.querySelector('[data-section="settings-users"]');
expect(btn).toBeTruthy();
expect(win.document.getElementById('settings-users')).toBeTruthy();
});
it('does NOT inject the Users tab for a regular user', async () => {
it('does NOT inject the Users section for a regular user', async () => {
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
expect(win.document.querySelector('[data-section="settings-users"]')).toBeFalsy();
});
it('does NOT inject the Users tab in single-user mode', async () => {
const { win } = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy();
expect(win.document.querySelector('[data-section="settings-users"]')).toBeFalsy();
});
it('shows the change-password modal when mustChangePassword is set', async () => {
+102
View File
@@ -0,0 +1,102 @@
/**
* App Settings structural guard.
*
* The settings modal is a rail (table of contents) over ONE scrolling document.
* Its load/save path is pure `getElementById` by a fixed set of ids
* (openAppSettings / saveAppSettings in settings-ui.js), so a restructure of the
* markup that drops or renames an element does not fail loudly: the setting just
* silently stops loading, or stops being saved and falls back to its default.
*
* These tests read the REAL settings-ui.js and index.html and pin that contract.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
/** The App Settings modal markup, so assertions can't be satisfied elsewhere. */
function settingsModal(): string {
const start = html.indexOf('<div class="modal" id="appSettingsModal">');
expect(start).toBeGreaterThan(-1);
const end = html.indexOf('<!-- Shortcut Overlay Modal -->', start);
expect(end).toBeGreaterThan(start);
return html.slice(start, end);
}
/**
* Every id the load and save paths touch. Scoped to those two functions on
* purpose: settings-ui.js also drives elements that live OUTSIDE the modal
* (toasts, header chips), and those are not this file's contract.
*/
function referencedIds(): string[] {
const ids = new Set<string>();
for (const fn of ['openAppSettings()', 'async saveAppSettings()']) {
const start = settingsUi.indexOf(`\n ${fn} {`);
expect(start, `${fn} not found in settings-ui.js`).toBeGreaterThan(-1);
const body = settingsUi.slice(start, settingsUi.indexOf('\n },', start));
for (const m of body.matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]);
}
return [...ids];
}
describe('App Settings modal structure', () => {
it('keeps every element settings-ui.js loads or saves by id', () => {
const modal = settingsModal();
const missing = referencedIds().filter((id) => !modal.includes(`id="${id}"`));
expect(missing).toEqual([]);
});
it('carries every section the rail points at, exactly once', () => {
const modal = settingsModal();
const sections = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
expect(sections.length).toBeGreaterThanOrEqual(9);
for (const id of new Set(sections)) {
const hits = modal.split(`<section class="set-section" id="${id}"`).length - 1;
expect(hits, `section ${id} should exist exactly once`).toBe(1);
}
});
it('opens on Terminal & Input, so Local Echo is the first thing in reach', () => {
expect(settingsUi).toContain("this.switchSettingsTab('settings-terminal')");
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
const cjk = terminal?.[1].indexOf('appSettingsCjkInput') ?? -1;
expect(localEcho).toBeGreaterThan(-1);
expect(localEcho).toBeLessThan(cjk);
});
it('models: keeps the 1M variants as select options behind the context switch', () => {
const modal = settingsModal();
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
// The cards render the base models; the [1m] rows exist so that base + the
// context switch can compose back into a real claudeModel value.
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-opus-4-6[1m]']) {
expect(select).toContain(`value="${value}"`);
}
expect(select).toContain('data-ctx="1"');
expect(modal).toContain('id="appSettingsOpusContext1m"');
});
it('never hides sections behind .modal-tab-content (that class means display:none)', () => {
expect(settingsModal()).not.toContain('modal-tab-content');
});
it('leaves the shared modal tab classes to the other modals', () => {
// #sessionOptionsModal and #createCaseModal still use .modal-tabs; the
// settings rail must not restyle them out from under those.
expect(settingsModal()).not.toContain('class="modal-tabs"');
expect(html).toContain('<div class="modal-tabs">');
});
it('exposes the rail hooks admin-ui.js injects the Users section into', () => {
const modal = settingsModal();
expect(modal).toContain('class="set-rail-items"');
expect(modal).toContain('id="appSettingsDoc"');
const adminUi = readFileSync(resolve(publicDir, 'admin-ui.js'), 'utf8');
expect(adminUi).toContain('.set-rail-items');
expect(adminUi).toContain('.set-doc');
});
});
+25 -25
View File
@@ -274,41 +274,41 @@ describe('Run launch synchronization', () => {
});
describe('Codex quick start settings', () => {
it('renders Codex CLI settings in a dedicated app settings tab', () => {
it('renders Codex CLI settings in their own group inside Agents & CLIs', () => {
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
expect(html).toContain('data-tab="settings-codex">Codex CLI</button>');
// The settings modal is one scrolling document: Codex is a GROUP that can be
// hidden wholesale, not a tab (see _applyCodexSettingsVisibility).
const clis = html.match(/<section class="set-section" id="settings-clis"([\s\S]*?)<\/section>/);
expect(clis?.[1]).toBeTruthy();
const claudeTab = html.match(
/<div class="modal-tab-content hidden" id="settings-claude">([\s\S]*?)<!-- Codex CLI Tab -->/
);
expect(claudeTab?.[1]).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(claudeTab?.[1]).not.toContain('appSettingsCodexAnimations');
const codexGroup = clis![1].match(/id="appSettingsCodexGroup"([\s\S]*)$/);
expect(codexGroup?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(codexGroup?.[1]).toContain('appSettingsCodexAnimations');
expect(codexGroup?.[1]).not.toContain('appSettingsCodexRenderMode');
const codexTab = html.match(
/<div class="modal-tab-content hidden" id="settings-codex">([\s\S]*?)<\/div>\s*<!-- Models Tab -->/
);
expect(codexTab?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(codexTab?.[1]).toContain('appSettingsCodexAnimations');
expect(codexTab?.[1]).not.toContain('appSettingsCodexRenderMode');
// The Claude settings above it must not have absorbed the codex inputs.
const beforeCodex = clis![1].slice(0, clis![1].indexOf('id="appSettingsCodexGroup"'));
expect(beforeCodex).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
expect(beforeCodex).not.toContain('appSettingsCodexAnimations');
});
describe('Codex CLI tab visibility', () => {
// Both settings on the tab are handed to `codex` at launch, so on an instance
// where the binary does not resolve the tab is a promise nothing can keep.
// renderIndexHtml injects window.__codemanCliAvailable; this pins the client
// half. Coupled test: it drives the REAL settings-ui.js against a stub button,
// so deleting the call in openAppSettings() is what it is meant to catch.
describe('Codex CLI group visibility', () => {
// Both settings in the group are handed to `codex` at launch, so on an
// instance where the binary does not resolve the group is a promise nothing
// can keep. renderIndexHtml injects window.__codemanCliAvailable; this pins
// the client half. Coupled test: it drives the REAL settings-ui.js against a
// stub element, so deleting the call in openAppSettings() is what it catches.
function loadSettingsUi(codexAvailable: boolean | undefined) {
const codexTabBtn = { dataset: { tab: 'settings-codex' }, style: { display: 'PRISTINE' } };
const codexTabBtn = { id: 'appSettingsCodexGroup', style: { display: 'PRISTINE' } };
const CodemanApp = function CodemanApp(this: any) {};
const context: any = vm.createContext({
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
localStorage: { getItem: () => null, setItem: () => {} },
document: {
getElementById: () => null,
querySelector: (sel: string) => (sel.includes('[data-tab="settings-codex"]') ? codexTabBtn : null),
getElementById: (id: string) => (id === 'appSettingsCodexGroup' ? codexTabBtn : null),
querySelector: () => null,
},
console,
});
@@ -319,19 +319,19 @@ describe('Codex quick start settings', () => {
return { app: new (CodemanApp as any)(), codexTabBtn };
}
it('hides the Codex tab when the codex binary is not available', () => {
it('hides the Codex group when the codex binary is not available', () => {
const { app, codexTabBtn } = loadSettingsUi(false);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('none');
});
it('hides the Codex tab when the availability flag was never injected', () => {
it('hides the Codex group when the availability flag was never injected', () => {
const { app, codexTabBtn } = loadSettingsUi(undefined);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('none');
});
it('shows the Codex tab when codex is available', () => {
it('shows the Codex group when codex is available', () => {
const { app, codexTabBtn } = loadSettingsUi(true);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('');
+4 -4
View File
@@ -32,8 +32,8 @@ describe('shortcut registry and overlay', () => {
expect(appSource).not.toContain("key: '/'");
});
it('exposes shortcut overrides in a dedicated App Settings shortcuts tab', () => {
expect(htmlSource).toContain('data-tab="settings-shortcuts"');
it('exposes shortcut overrides in a dedicated App Settings shortcuts section', () => {
expect(htmlSource).toContain('data-section="settings-shortcuts"');
expect(htmlSource).toContain('id="settings-shortcuts"');
expect(htmlSource).toContain('id="appSettingsShortcutsList"');
expect(htmlSource).not.toContain('id="appSettingsShortcutOverrides"');
@@ -192,7 +192,7 @@ describe('shortcut settings persistence and capture', () => {
expect(app.showToast).toHaveBeenCalledWith('Shortcut must include Ctrl, Cmd, or Alt', 'error');
});
it('renders the shortcuts list when the Shortcuts settings tab is opened', () => {
it('renders the shortcuts list when the Shortcuts settings section is reached', () => {
const { app, elements } = loadSettingsHarness();
elements.appSettingsModal = { querySelectorAll: () => [] };
app.renderShortcutSettingsList = vi.fn();
@@ -200,7 +200,7 @@ describe('shortcut settings persistence and capture', () => {
app.switchSettingsTab('settings-shortcuts');
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
app.switchSettingsTab('settings-display');
app.switchSettingsTab('settings-terminal');
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
});