diff --git a/CLAUDE.md b/CLAUDE.md index 65b4b0f6..c6d3306e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.('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`. +**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.('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 and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). 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/`. diff --git a/docs/readmymind-plan.md b/docs/readmymind-plan.md index 101bffd2..6b251a52 100644 --- a/docs/readmymind-plan.md +++ b/docs/readmymind-plan.md @@ -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.** 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`). +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 (shipped): rethink steering, the free-text steer note under the suggestions, sent as `steer`, visible whenever Rethink is live (ready and empty-result phases), cleared on each open; the empty-result copy points at the note, and the footer buttons moved to the styled `btn-toolbar` convention (the bare `btn btn-*` classes they shipped with match no CSS in this codebase and rendered as unstyled UA buttons). 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 diff --git a/docs/readmymind.md b/docs/readmymind.md index 22dda122..45caa384 100644 --- a/docs/readmymind.md +++ b/docs/readmymind.md @@ -27,7 +27,7 @@ On a Claude session, press the brain button in the header (desktop) or the 🧠 - **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 everything shown (the field and the alternates) recorded as rejected. +- **Rethink** re-runs with everything shown (the field and the alternates) recorded as rejected. An optional steer note below the suggestions ("no, I meant the mobile bug") rides along as your own words, the highest-authority signal the predictor gets; it stays in the field across re-runs until you clear it or reopen the modal. - **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. @@ -87,7 +87,7 @@ The `codeman` agent skill documents the same verbs (SKILL.md §3 plus `reference ## What comes next -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). +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 diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index a9fd9849..db9c6a27 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -255,12 +255,15 @@ 'Read My Mind: predict your next prompt': '读心术:预测您的下一条提示', 'Predict my next prompt': '预测我的下一条提示', 'Reading your mind…': '正在读取您的想法…', - 'No suggestion this time. Rethink to try again.': '这次没有建议。点击「重想」再试一次。', + 'No suggestion this time. Add a steer note and Rethink to try again.': + '这次没有建议。可添加引导备注后点击「重想」再试一次。', Rethink: '重想', Insert: '插入', "Put the text on the session's composer without submitting it": '将文本放入会话输入框但不提交', 'Predicted prompt, editable': '预测的提示,可编辑', 'Use this suggestion instead': '改用此建议', + "Steer the rethink, e.g. 'no, I meant the mobile bug'": '引导重想,例如:"不,我是指移动端的问题"', + 'Steer note for Rethink': '重想的引导备注', 'Select a session first': '请先选择一个会话', 'Read My Mind works on Claude sessions only': '读心术仅适用于 Claude 会话', 'Prompt sent': '提示已发送', diff --git a/src/web/public/index.html b/src/web/public/index.html index f2d27ecd..be1651d9 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3107,13 +3107,23 @@
- + + + + diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index e262e5d6..cfea0252 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1318,20 +1318,22 @@ html.mobile-init .file-browser-panel { 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. */ + and give buttons + alternate rows finger-sized targets. The flex row + itself comes from the base rule in styles.css. */ .readmymind-modal .modal-footer { - display: flex; flex-wrap: wrap; - justify-content: flex-end; - gap: 0.5rem; } - .readmymind-modal .modal-footer .btn { + .readmymind-modal .modal-footer .btn-toolbar { flex: 1 1 auto; + justify-content: center; min-height: 38px; } .readmymind-alt { min-height: 38px; } + .readmymind-steer-input { + min-height: 38px; + } /* Modal safe area padding - all sides for full-screen modals */ .ios-device .modal-content { diff --git a/src/web/public/readmymind-ui.js b/src/web/public/readmymind-ui.js index bde315a3..ee3bb4f5 100644 --- a/src/web/public/readmymind-ui.js +++ b/src/web/public/readmymind-ui.js @@ -10,7 +10,8 @@ * 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. + * set, main + alternates, recorded as rejected, plus the optional free-text + * steer note, e.g. "no, I meant the mobile bug", sent as `steer`), Dismiss. * * Suggestions are NEVER auto-sent: the explicit click here is the security * boundary for observed/injectable predictor inputs, so suggestion text is @@ -47,8 +48,11 @@ Object.assign(CodemanApp.prototype, { this.showToast('Read My Mind works on Claude sessions only', 'warning'); return; } - // Rethink memory resets on each open (a fresh open is a fresh question). + // Rethink memory resets on each open (a fresh open is a fresh question), + // and the steer note resets with it. this._rmm = { sessionId, suggestions: [], selected: 0, rejected: [], busy: false }; + const steer = document.getElementById('readMyMindSteer'); + if (steer) steer.value = ''; document.getElementById('readMyMindModal')?.classList.add('active'); this._readMyMindPredict(); }, @@ -65,7 +69,13 @@ Object.assign(CodemanApp.prototype, { state.busy = true; this._rmmSetPhase('loading'); - const body = state.rejected.length > 0 ? { rejected: state.rejected.slice(-10) } : {}; + const body = {}; + if (state.rejected.length > 0) body.rejected = state.rejected.slice(-10); + // The steer note rides every re-run while it stays in the field: what the + // user sees in the box is what the predictor gets. Empty on first open + // (openReadMyMind clears it), so a plain predict sends neither key. + const steer = document.getElementById('readMyMindSteer')?.value.trim() ?? ''; + if (steer) body.steer = steer.slice(0, 2000); const data = await this._apiJson(`/api/sessions/${state.sessionId}/readmymind`, { method: 'POST', body }); // The modal may have been dismissed (or reopened for another session) while @@ -176,7 +186,8 @@ Object.assign(CodemanApp.prototype, { }, /** Re-run with the whole shown set (main + alternates) recorded as rejected: - * the user saw every row and asked for something else. */ + * the user saw every row and asked for something else. The steer note (if + * any) is read from the field by _readMyMindPredict itself. */ rethinkReadMyMind() { const state = this._rmm; if (!state || state.busy) return; @@ -193,6 +204,11 @@ Object.assign(CodemanApp.prototype, { modal.querySelector('.readmymind-loading').style.display = phase === 'loading' ? '' : 'none'; modal.querySelector('.readmymind-result').style.display = phase === 'ready' ? '' : 'none'; modal.querySelector('.readmymind-error').style.display = phase === 'error' ? '' : 'none'; + // The steer note belongs to Rethink, so it shows wherever Rethink is live: + // the ready phase AND the empty-result phase (typed text survives the + // loading round-trip, only the row's visibility toggles). + const steerRow = document.getElementById('readMyMindSteerRow'); + if (steerRow) steerRow.style.display = phase === 'loading' ? 'none' : ''; const rethinkBtn = document.getElementById('readMyMindRethink'); if (rethinkBtn) rethinkBtn.disabled = phase === 'loading'; }, diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 9bc5f5f2..463d0460 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -10933,6 +10933,58 @@ kbd { white-space: nowrap; font-family: var(--mono-font, monospace); } +/* Rethink steer note: the user's own words about what they actually meant, + sent as `steer` on the next re-run (readmymind-ui.js _readMyMindPredict). + Dashed border marks it as the optional side channel, distinct from the + primary suggestion field above; it solidifies on focus. */ +.readmymind-steer-row { + margin-top: 10px; +} +.readmymind-steer-input { + width: 100%; + min-width: 0; + font-size: 12px; + padding: 7px 10px; + background: transparent; + color: var(--text); + border: 1px dashed var(--control-border); + border-radius: 8px; +} +.readmymind-steer-input:focus { + outline: none; + border-style: solid; + border-color: var(--accent); + background: var(--bg-dark); +} +.readmymind-steer-input::placeholder { + color: var(--text-dim); +} +/* Footer row: the buttons are btn-toolbar (display: flex, block-level), so + without this rule the four of them stack vertically. Mirrors the + runSummaryModal footer; the ≤430px block in mobile.css adds wrapping. */ +.readmymind-modal .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} +/* Send keeps the primary accent: the skin block's bare .btn-toolbar rule is + (0,2,1) under html:not([data-skin="og"]) and outranks the base + .btn-toolbar.btn-primary (0,2,0) — the specificity trap CLAUDE.md documents + for mobile.css — so the accent is re-asserted here at (0,4,0). Scoped to + this modal on purpose; un-greying every btn-primary on the new skins is a + design call, not this feature's. */ +.readmymind-modal .modal-footer .btn-toolbar.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.readmymind-modal .modal-footer .btn-toolbar.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + color: #fff; +} /* 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 diff --git a/test/readmymind-phone-key.test.ts b/test/readmymind-phone-key.test.ts index a4b38b99..c3f938e5 100644 --- a/test/readmymind-phone-key.test.ts +++ b/test/readmymind-phone-key.test.ts @@ -73,4 +73,36 @@ describe('read my mind phone key + alternates (static guards)', () => { expect(ui).toContain('readMyMindAlternates'); expect(ui).toMatch(/\.textContent = suggestion\.prompt/); }); + + // Phase 3 part 2: the Rethink steer note (docs/readmymind-plan.md phase 3). + it('wires the rethink steer note end to end: field, payload, phase visibility, reset', () => { + // The field lives in the modal, capped to the schema's 2000-char limit, + // and Enter in it triggers a rethink (mirroring the prompt field's + // Enter-to-send). + expect(html).toMatch(/id="readMyMindSteer"[^>]*maxlength="2000"/); + expect(html).toMatch(/id="readMyMindSteer"[^>]*onkeydown="[^"]*rethinkReadMyMind\(\)"/); + // Predict sends the trimmed note as `steer`, bounded to the schema cap. + expect(ui).toMatch(/body\.steer = steer\.slice\(0, 2000\)/); + // The row hides ONLY during loading: Rethink is live in both the ready + // and the empty-result phases, so the note must be reachable in both. + expect(ui).toMatch(/steerRow\.style\.display = phase === 'loading' \? 'none' : ''/); + // A fresh open resets the note along with the rethink memory. + expect(ui).toMatch(/steer\.value = ''/); + }); + + it('styles the footer with btn-toolbar (bare "btn btn-*" matches no CSS in this codebase)', () => { + const modal = html.slice(html.indexOf('id="readMyMindModal"'), html.indexOf('id="approvalsDrawer"')); + // The unstyled classes the footer originally shipped with must not return. + expect(modal).not.toMatch(/class="btn /); + expect(modal.match(/class="btn-toolbar/g)?.length).toBe(4); + expect(modal).toMatch(/class="btn-toolbar btn-primary"[^>]*sendReadMyMind\(true\)/); + // btn-toolbar is display:flex (block-level): without the desktop footer + // row rule the four buttons would stack vertically. + expect(styles).toMatch(/\.readmymind-modal \.modal-footer \{[^}]*display: flex/); + // The skin block's bare .btn-toolbar (0,2,1) greys out .btn-primary + // (0,2,0), so Send's accent must be re-asserted at higher specificity. + expect(styles).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar\.btn-primary \{[^}]*var\(--accent\)/); + // The phone block sizes the same class for finger targets. + expect(phoneBlock).toMatch(/\.readmymind-modal \.modal-footer \.btn-toolbar/); + }); });