feat(readmymind): alternates row, phone accessory key, phone-sized modal (phase 3 part 1)

The Read My Mind modal grows up and reaches phones:

- Alternate suggestions (the predictor's verify/redirect kinds) now render
  as tappable rows below the main field. Tapping one swaps it into the
  editable field; the edit you were making folds back into the row you
  leave, so toggling between alternates never loses typing. Rethink now
  records the WHOLE shown set (main + alternates) as rejected.
- Phones get a 🧠 key on the keyboard accessory bar (both simple and
  extended layouts), gated on the same synced readMyMindEnabled setting
  via an rmm-enabled marker class on the BAR element: setMode() rebuilds
  the buttons' innerHTML, so per-key state would be wiped. Synced at init
  and re-synced by applyHeaderVisibilitySettings() on every settings
  apply, so a live toggle needs no reload. The header button stays off
  phones.
- On phones the modal renders as a small dialog (mirrors modal-sm) instead
  of the full-screen default, with wrap-friendly finger-sized footer
  buttons. Not modal-sm itself: that caps desktop width at 340px and this
  modal wants 560px there.
- On touch devices the ready/swap paths no longer focus the field, so the
  OS keyboard does not pop over the alternates that just rendered.
- New static guard test/readmymind-phone-key.test.ts pins the dual-template
  key, the marker-class gating, the phone-hidden header button, the
  small-dialog phone modal, and the no-innerHTML discipline.

Part 2 of phase 3 (rethink steering, the free-text steer note) is next;
the API already accepts steer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-10 03:57:06 +02:00
parent c891a8045d
commit a070fc43ea
11 changed files with 272 additions and 26 deletions
+1 -1
View File
@@ -210,7 +210,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny buttons are also gated on it (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON, desktop-only (mobile.css hides it; phone key is phase 3); suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set. Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
+1 -1
View File
@@ -124,7 +124,7 @@ Agent use cases this unlocks: a lead session records intentions as the user stat
1. **Intent store + capture + intent endpoints + skill docs.** Immediately useful to agents even before any UI exists.
2. **Context assembler + predictor + predict endpoint + desktop button/modal.** The feature as pitched. The assembler ships with all collectors it can serve from day one (transcript, intent, git, run-summary, siblings); the approvals collector activates when PR #245 lands.
3. **Phone accessory key, rethink steering, alternates row.**
3. **Phone accessory key, rethink steering, alternates row.** Part 1 (shipped): the alternates row (tappable, swap into the field without losing edits; Rethink rejects the whole shown set), the phone 🧠 keyboard-accessory key (both bar templates, `rmm-enabled` marker class on the bar), and a phone-sized modal (small dialog, not full-screen). Part 2: rethink steering (the free-text steer note; the API already accepts `steer`).
4. Explicitly later: proactive predict-on-idle (ghost suggestion chip), auto-compaction of `recentPrompts` into `goals` via a cheap model, codex/gemini capture, cross-case "global" intent.
## Open questions
+5 -5
View File
@@ -23,11 +23,11 @@ Add `-u user:password` if your install has `CODEMAN_PASSWORD` set, and drop `-k`
## The 🧠 button
On a Claude session, press the brain button in the header (desktop; the phone surface is a planned keyboard-accessory key). Codeman assembles everything it already knows: your goals, your recent prompts (with your voice: length, tone, shorthand), the tail of the last assistant reply, recent tool activity, git state (branch, dirty files, pending changesets), how long you have been away and what happened meanwhile, sibling sessions in the same case, and any dialog the session is currently waiting on. A one-shot model call (opus by default, `readMyMindModel` to override) turns that into 1-3 suggestions; the top one lands in an editable field with its rationale.
On a Claude session, press the brain button in the header (desktop) or the 🧠 key on the keyboard accessory bar (phones and tablets; it appears when the setting is on). Codeman assembles everything it already knows: your goals, your recent prompts (with your voice: length, tone, shorthand), the tail of the last assistant reply, recent tool activity, git state (branch, dirty files, pending changesets), how long you have been away and what happened meanwhile, sibling sessions in the same case, and any dialog the session is currently waiting on. A one-shot model call (opus by default, `readMyMindModel` to override) turns that into 1-3 suggestions; the top one lands in an editable field with its rationale, and the others render as tappable alternate rows: tap one to swap it into the field (edits you already made are kept on the row you leave).
- **Send** submits it to the session (with Enter).
- **Insert** drops it on the CLI composer *without* Enter, so you can edit it in the terminal before sending.
- **Rethink** re-runs with the shown suggestion recorded as rejected.
- **Rethink** re-runs with everything shown (the field and the alternates) recorded as rejected.
- **Dismiss** closes; nothing happens.
A prediction takes 5-90 seconds and costs real tokens; one runs per session at a time. If the session is sitting on a permission/question dialog, the suggestion is usually an answer to that dialog: that is intentional.
@@ -85,15 +85,15 @@ A case with nothing recorded answers an empty profile with `updatedAt: 0`; reads
The `codeman` agent skill documents the same verbs (SKILL.md §3 plus `reference/endpoints.md`), with the ground rules: read the profile to understand what the user wants, record goals the user actually stated, merge instead of blind-writing (PUT replaces), never delete a profile unprompted, and never send a predicted suggestion into a session unless the user asked. It is the user's memory, not the agent's.
## What comes next (phase 3+)
## What comes next
Phone keyboard-accessory 🧠 key, a steer-note input on Rethink, and tappable alternate suggestions. Explicitly later: proactive predict-on-idle, auto-compaction of the prompt history into goals, non-Claude capture. See the phases section of [`readmymind-plan.md`](readmymind-plan.md).
A steer-note input on Rethink ("no, I meant the mobile bug"; the API already accepts `steer`). Explicitly later: proactive predict-on-idle, auto-compaction of the prompt history into goals, non-Claude capture. See the phases section of [`readmymind-plan.md`](readmymind-plan.md).
## Troubleshooting
| Symptom | Cause / fix |
| ------- | ----------- |
| No 🧠 button in the header | `readMyMindEnabled` is OFF (App Settings → Panels), you are on a phone (desktop-only in this phase), or the active session is not claude-mode |
| No 🧠 button in the header | `readMyMindEnabled` is OFF (App Settings → Panels), you are on a phone (there it is a key on the keyboard accessory bar instead, visible while typing), or the active session is not claude-mode |
| Prediction feels generic | The profile is thin: record goals (PUT or ask your agent to), and let capture accumulate a few real prompts first |
| "A prediction is already running" (409) | One per session at a time; wait for the current one (up to 90 s) |
| Prediction fails (502) | The model returned no usable JSON, or the CLI could not start; retry. Check `readMyMindModel` if you overrode it |
+1
View File
@@ -260,6 +260,7 @@
Insert: '插入',
"Put the text on the session's composer without submitting it": '将文本放入会话输入框但不提交',
'Predicted prompt, editable': '预测的提示,可编辑',
'Use this suggestion instead': '改用此建议',
'Select a session first': '请先选择一个会话',
'Read My Mind works on Claude sessions only': '读心术仅适用于 Claude 会话',
'Prompt sent': '提示已发送',
+1
View File
@@ -2813,6 +2813,7 @@
<input type="text" id="readMyMindPrompt" class="readmymind-prompt-input" data-i18n-skip aria-label="Predicted prompt, editable" onkeydown="if(event.key==='Enter')app.sendReadMyMind(true)">
</div>
<div class="readmymind-why" id="readMyMindWhy" data-i18n-skip></div>
<div class="readmymind-alternates" id="readMyMindAlternates" data-i18n-skip style="display:none"></div>
</div>
<div class="readmymind-error" style="display:none">No suggestion this time. Rethink to try again.</div>
</div>
+21
View File
@@ -441,6 +441,7 @@ const KeyboardAccessoryBar = {
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
@@ -478,6 +479,7 @@ const KeyboardAccessoryBar = {
</button>
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">&#x1F4C1; Path</button>
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">&#x232B; All</button>
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
@@ -502,6 +504,9 @@ const KeyboardAccessoryBar = {
this.element = document.createElement('div');
this.element.className = 'keyboard-accessory-bar';
this.element.innerHTML = this._simpleButtons;
// The 🧠 key is opt-in (`readMyMindEnabled`, synced): it ships in both
// templates but stays display:none until the bar carries the marker class.
this.syncReadMyMind();
// Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => {
@@ -607,6 +612,11 @@ const KeyboardAccessoryBar = {
}
break;
}
case 'readmymind':
// Opens the shared Read My Mind modal (readmymind-ui.js); the modal
// takes focus, so deliberately NOT in the terminal-refocus set.
app.openReadMyMind?.();
break;
case 'paste':
this.pasteFromClipboard();
break;
@@ -652,6 +662,17 @@ const KeyboardAccessoryBar = {
this._confirmAction = null;
},
/** Reveal/hide the 🧠 key from the synced `readMyMindEnabled` setting.
* The marker class lives on the BAR because setMode() rebuilds the buttons'
* innerHTML on every layout switch (per-key state would be wiped). Called at
* init and re-synced by applyHeaderVisibilitySettings() on every settings
* apply, so a live toggle needs no reload. */
syncReadMyMind() {
if (!this.element) return;
const enabled = typeof app !== 'undefined' && typeof app.readMyMindEnabled === 'function' && app.readMyMindEnabled();
this.element.classList.toggle('rmm-enabled', enabled === true);
},
/** Send a slash command to the active session.
* Sends text and Enter separately so Ink processes them as distinct events. */
sendCommand(command) {
+30 -2
View File
@@ -530,8 +530,9 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
/* Read My Mind 🧠 button: desktop header only in phase 2; the phone surface
is a planned keyboard-accessory key (docs/readmymind-plan.md phase 3). */
/* Read My Mind 🧠 header button: never in the phone header; the phone
surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate,
see keyboard-accessory.js + the rmm-enabled rules in styles.css). */
.btn-icon-header.btn-readmymind {
display: none !important;
}
@@ -1299,6 +1300,33 @@ html.mobile-init .file-browser-panel {
width: calc(100% - 2rem);
}
/* Read My Mind: a small dialog (mirrors modal-sm), not a full-screen
takeover — it opens over the keyboard from the accessory 🧠 key and
should read as a quick suggestion sheet. Not modal-sm itself because
that caps desktop width at 340px; this modal wants 560px there. */
.modal-content.readmymind-modal {
height: auto;
max-height: 85vh;
border-radius: 12px;
margin: 1rem;
width: calc(100% - 2rem);
}
/* Four footer buttons on a narrow phone: let them wrap instead of clipping,
and give buttons + alternate rows finger-sized targets. */
.readmymind-modal .modal-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.readmymind-modal .modal-footer .btn {
flex: 1 1 auto;
min-height: 38px;
}
.readmymind-alt {
min-height: 38px;
}
/* Modal safe area padding - all sides for full-screen modals */
.ios-device .modal-content {
padding-top: var(--safe-area-top);
+80 -16
View File
@@ -2,12 +2,15 @@
* @fileoverview Read My Mind UI: predict the prompt you were about to type.
*
* A 🧠 header button (marker-hidden until the synced opt-in `readMyMindEnabled`
* setting is ON) opens a modal that asks the server for the user's most likely
* setting is ON; phones get a keyboard-accessory 🧠 key gated on the same
* setting) opens a modal that asks the server for the user's most likely
* next prompt (`POST /api/sessions/:id/readmymind`, one-shot predictor over the
* case's intent profile + live session signals). The top suggestion lands in an
* editable single-line field with its rationale below; buttons are Send (with
* Enter), Insert (drop on the CLI composer WITHOUT Enter, for editing), Rethink
* (re-run with the shown suggestion recorded as rejected), Dismiss.
* editable single-line field with its rationale below; the predictor's other
* suggestions render as tappable alternate rows that swap into the field
* without losing edits. Buttons are Send (with Enter), Insert (drop on the CLI
* composer WITHOUT Enter, for editing), Rethink (re-run with the whole shown
* set, main + alternates, recorded as rejected), Dismiss.
*
* Suggestions are NEVER auto-sent: the explicit click here is the security
* boundary for observed/injectable predictor inputs, so suggestion text is
@@ -20,6 +23,7 @@
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.sessions, this.activeSessionId, showToast)
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice, focus policy)
* @dependency settings-ui.js (loadAppSettingsFromStorage)
* @dependency api-client.js at runtime (this._apiJson; loads later but is only called after init)
* @loadorder 11.3, after panels-ui.js, before ultracode-panel.js
@@ -44,7 +48,7 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Rethink memory resets on each open (a fresh open is a fresh question).
this._rmm = { sessionId, shown: null, rejected: [], busy: false };
this._rmm = { sessionId, suggestions: [], selected: 0, rejected: [], busy: false };
document.getElementById('readMyMindModal')?.classList.add('active');
this._readMyMindPredict();
},
@@ -54,7 +58,7 @@ Object.assign(CodemanApp.prototype, {
this._rmm = null;
},
/** Run (or re-run) the prediction and render the top suggestion. */
/** Run (or re-run) the prediction and render the suggestion set. */
async _readMyMindPredict() {
const state = this._rmm;
if (!state || state.busy) return;
@@ -69,26 +73,83 @@ Object.assign(CodemanApp.prototype, {
if (this._rmm !== state) return;
state.busy = false;
const suggestion = data && data.suggestions && data.suggestions[0];
if (!suggestion) {
const suggestions = (data && Array.isArray(data.suggestions) ? data.suggestions : []).filter(
(s) => s && typeof s.prompt === 'string' && s.prompt.trim()
);
if (suggestions.length === 0) {
this._rmmSetPhase('error');
return;
}
state.shown = suggestion;
state.suggestions = suggestions.slice(0, 3);
state.selected = 0;
this._rmmSetPhase('ready');
this._rmmRender();
this._rmmFocusPrompt();
},
/** Paint the selected suggestion into the editable field, the rest as alternates. */
_rmmRender() {
const state = this._rmm;
const current = state && state.suggestions[state.selected];
if (!current) return;
const input = document.getElementById('readMyMindPrompt');
const why = document.getElementById('readMyMindWhy');
const kind = document.getElementById('readMyMindKind');
// Predictor output is derived from observable (injectable) content:
// value/textContent only, never innerHTML.
if (input) input.value = suggestion.prompt;
if (why) why.textContent = suggestion.why || '';
if (input) input.value = current.prompt;
if (why) why.textContent = current.why || '';
if (kind) {
kind.textContent = suggestion.kind || 'continue';
kind.className = `readmymind-kind readmymind-kind-${suggestion.kind || 'continue'}`;
kind.textContent = current.kind || 'continue';
kind.className = `readmymind-kind readmymind-kind-${current.kind || 'continue'}`;
}
input?.focus();
const alternates = document.getElementById('readMyMindAlternates');
if (!alternates) return;
alternates.replaceChildren();
// The container is data-i18n-skip (suggestion text must never be mistaken
// for app copy), so the one piece of app copy inside it is pre-translated.
const translate = window.codemanT || ((s) => s);
state.suggestions.forEach((suggestion, index) => {
if (index === state.selected) return;
const row = document.createElement('button');
row.type = 'button';
row.className = 'readmymind-alt';
row.title = suggestion.why || '';
row.setAttribute('aria-label', translate('Use this suggestion instead'));
const badge = document.createElement('span');
badge.className = `readmymind-kind readmymind-kind-${suggestion.kind || 'continue'}`;
badge.textContent = suggestion.kind || 'continue';
const text = document.createElement('span');
text.className = 'readmymind-alt-text';
text.textContent = suggestion.prompt;
row.append(badge, text);
row.addEventListener('click', () => this._rmmSelect(index));
alternates.appendChild(row);
});
alternates.style.display = alternates.childElementCount > 0 ? '' : 'none';
},
/** Swap an alternate into the field, folding the current edit back first. */
_rmmSelect(index) {
const state = this._rmm;
if (!state || state.busy || !state.suggestions[index]) return;
const input = document.getElementById('readMyMindPrompt');
const current = state.suggestions[state.selected];
// Keep edits: fold the field text back into the suggestion it belongs to,
// so toggling between alternates never loses typing.
if (input && current) current.prompt = input.value;
state.selected = index;
this._rmmRender();
this._rmmFocusPrompt();
},
/** Focus the editable field on desktop. On touch devices leave it blurred so
* the OS keyboard doesn't pop over the alternates that just rendered. */
_rmmFocusPrompt() {
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice()) return;
document.getElementById('readMyMindPrompt')?.focus();
},
/**
@@ -114,11 +175,14 @@ Object.assign(CodemanApp.prototype, {
this.showToast(withEnter ? 'Prompt sent' : 'Inserted, press Enter in the terminal to send', 'success');
},
/** Re-run with the shown suggestion recorded as a rejection. */
/** Re-run with the whole shown set (main + alternates) recorded as rejected:
* the user saw every row and asked for something else. */
rethinkReadMyMind() {
const state = this._rmm;
if (!state || state.busy) return;
if (state.shown && state.shown.prompt) state.rejected.push(state.shown.prompt);
for (const suggestion of state.suggestions) {
if (suggestion.prompt && suggestion.prompt.trim()) state.rejected.push(suggestion.prompt);
}
this._readMyMindPredict();
},
+6 -1
View File
@@ -2112,11 +2112,16 @@ Object.assign(CodemanApp.prototype, {
// Read My Mind 🧠 — hidden unless the synced opt-in `readMyMindEnabled` is
// ON (only an explicit true enables, mirroring the Approvals bell). Marker
// class (base is display:inline-flex !important); phones hide it in
// mobile.css regardless (the phase-3 surface there is an accessory key).
// mobile.css regardless (their surface is the keyboard-accessory 🧠 key,
// re-synced right below).
const readMyMindBtn = document.querySelector('.btn-readmymind');
if (readMyMindBtn) {
readMyMindBtn.classList.toggle('btn-readmymind--hidden', settings.readMyMindEnabled !== true);
}
// The accessory-bar 🧠 key shares the setting; its marker class lives on
// the bar element (keyboard-accessory.js), so a live toggle from a
// settings save reveals/hides it without a reload.
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.syncReadMyMind?.();
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
// Settings → Display → "Plan Usage Limits"). The template always ships it
+50
View File
@@ -10757,6 +10757,56 @@ kbd {
padding: 12px 4px;
color: var(--text-dim);
}
/* Alternate suggestions (the predictor's verify/redirect kinds): tappable rows
that swap into the editable field (readmymind-ui.js _rmmSelect). */
.readmymind-alternates {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.readmymind-alt {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
text-align: left;
padding: 7px 10px;
background: transparent;
border: 1px solid var(--control-border);
border-radius: 8px;
color: var(--text-dim);
cursor: pointer;
font-size: 12px;
}
.readmymind-alt:hover,
.readmymind-alt:focus-visible {
border-color: var(--accent);
color: var(--text);
}
.readmymind-alt-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono-font, monospace);
}
/* Keyboard-accessory 🧠 key: the phone surface for the same opt-in setting
(the header 🧠 button stays phone-hidden in mobile.css). The key ships in
BOTH bar templates (keyboard-accessory.js) but stays hidden until the bar
carries the marker class: setMode() rebuilds the bar's innerHTML on every
layout switch, so the gate must live on the bar element, not on the key.
Lives here rather than mobile.css because large touch tablets (>1023px)
never load mobile.css but do render the accessory bar. */
.keyboard-accessory-bar .accessory-btn-rmm {
display: none;
}
.keyboard-accessory-bar.rmm-enabled .accessory-btn-rmm {
display: inline-flex;
}
.approvals-badge {
position: absolute;
+76
View File
@@ -0,0 +1,76 @@
// Port: none (pure static analysis — runs in CI, no browser/server).
//
// Read My Mind phase 3 part 1 guards: the modal's alternates row and the phone
// keyboard-accessory 🧠 key. The mobile Playwright suite is excluded from CI,
// so like test/mobile-header-buttons-policy.test.ts this parses the frontend
// assets directly to pin the wiring that only a phone would exercise:
//
// 1. the 🧠 key ships in BOTH accessory-bar templates (setMode() swaps the
// bar's innerHTML between them, so a key present in only one layout would
// silently vanish when the user toggles `extendedKeyboardBar`) and routes
// to the shared modal;
// 2. the key is hidden unless the bar carries the `rmm-enabled` marker class
// — gating must live on the BAR element because setMode() rebuilds the
// buttons — synced at init and re-synced by settings-ui.js on every
// settings apply (a live toggle needs no reload);
// 3. the header 🧠 button STAYS off phones (the key is the phone surface);
// 4. the modal renders on phones as a small dialog, not the full-screen
// default that phone `.modal-content` rules would impose;
// 5. readmymind-ui.js keeps the no-innerHTML discipline (predictor output is
// injectable content) and renders alternates into the i18n-skipped
// container declared in index.html.
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const PUBLIC = join(HERE, '../src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf-8');
describe('read my mind phone key + alternates (static guards)', () => {
const accessory = read('keyboard-accessory.js');
const styles = read('styles.css');
const mobile = read('mobile.css');
const html = read('index.html');
const ui = read('readmymind-ui.js');
const settingsUi = read('settings-ui.js');
// Everything phone-specific lives in the max-width 430px block of mobile.css.
const phoneBlock = mobile.slice(mobile.indexOf('@media (max-width: 430px)'));
it('ships the 🧠 key in BOTH accessory bar templates and routes it to the modal', () => {
const simple = accessory.match(/_simpleButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
const extended = accessory.match(/_extendedButtons\s*:\s*`([\s\S]*?)`/)?.[1] ?? '';
expect(simple).toMatch(/accessory-btn-rmm[^>]*data-action="readmymind"/);
expect(extended).toMatch(/accessory-btn-rmm[^>]*data-action="readmymind"/);
expect(accessory).toMatch(/case 'readmymind':/);
expect(accessory).toMatch(/openReadMyMind/);
});
it('hides the key until the bar carries rmm-enabled, synced at init and on settings apply', () => {
expect(styles).toMatch(/\.keyboard-accessory-bar \.accessory-btn-rmm \{\s*display: none;/);
expect(styles).toMatch(/\.keyboard-accessory-bar\.rmm-enabled \.accessory-btn-rmm \{\s*display: inline-flex;/);
// init() applies the gate as soon as the bar exists…
expect(accessory).toMatch(/this\.syncReadMyMind\(\);/);
// …and settings-ui re-syncs it on every settings apply (live toggle).
expect(settingsUi).toMatch(/KeyboardAccessoryBar\.syncReadMyMind/);
});
it('keeps the header 🧠 button off phones (the accessory key is the phone surface)', () => {
expect(phoneBlock).toMatch(/\.btn-icon-header\.btn-readmymind\s*\{\s*display: none !important;/);
});
it('renders the modal as a small dialog on phones, not the full-screen default', () => {
expect(phoneBlock).toMatch(/\.modal-content\.readmymind-modal\s*\{[^}]*height: auto;/);
});
it('declares the i18n-skipped alternates container and keeps the no-innerHTML discipline', () => {
expect(html).toMatch(/id="readMyMindAlternates"[^>]*data-i18n-skip/);
// Predictor output is injectable content: value/textContent only, ever.
// (`.innerHTML`: property ACCESS — the fileoverview's "never innerHTML"
// prose is allowed to say the word.)
expect(ui).not.toMatch(/\.innerHTML/);
expect(ui).toContain('readMyMindAlternates');
expect(ui).toMatch(/\.textContent = suggestion\.prompt/);
});
});