From ba08f21f0689382559fe9f9d3c9fced263032176 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 01:32:30 +0000 Subject: [PATCH] Add live word/character count indicator to the chat composer Adds a small draft count indicator to the composer toolbar that shows the current word count inline (hidden when the draft is empty or in compact mode) with a hover tooltip breaking down words, characters, and lines. - composer-count.ts: pure computeComposerCounts/shouldShowComposerCount helpers (Unicode-aware character count, whitespace-collapsing words, newline lines) with unit tests. - ComposerCountIndicator.tsx: derived-state indicator over the composer's existing plain-text input; exposes data-* counts for e2e. - Wires the indicator into the FreeFormInput toolbar bottom row. - Adds chat.composerWords/Characters/Lines i18n keys (plural-neutral label form) to all 7 locales. - e2e/assertions/composer-count.assert.ts: CDP assertion driving real typing. Closes #60 --- .../input/ComposerCountIndicator.tsx | 54 +++++++ .../app-shell/input/FreeFormInput.tsx | 4 + .../lib/__tests__/composer-count.test.ts | 56 ++++++++ .../src/renderer/lib/composer-count.ts | 44 ++++++ docs/loop/feature-ledger.md | 1 + e2e/assertions/composer-count.assert.ts | 132 ++++++++++++++++++ packages/shared/src/i18n/locales/de.json | 3 + packages/shared/src/i18n/locales/en.json | 3 + packages/shared/src/i18n/locales/es.json | 3 + packages/shared/src/i18n/locales/hu.json | 3 + packages/shared/src/i18n/locales/ja.json | 3 + packages/shared/src/i18n/locales/pl.json | 3 + packages/shared/src/i18n/locales/zh-Hans.json | 3 + 13 files changed, 312 insertions(+) create mode 100644 apps/electron/src/renderer/components/app-shell/input/ComposerCountIndicator.tsx create mode 100644 apps/electron/src/renderer/lib/__tests__/composer-count.test.ts create mode 100644 apps/electron/src/renderer/lib/composer-count.ts create mode 100644 e2e/assertions/composer-count.assert.ts diff --git a/apps/electron/src/renderer/components/app-shell/input/ComposerCountIndicator.tsx b/apps/electron/src/renderer/components/app-shell/input/ComposerCountIndicator.tsx new file mode 100644 index 000000000..4a5ae584c --- /dev/null +++ b/apps/electron/src/renderer/components/app-shell/input/ComposerCountIndicator.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { Tooltip, TooltipContent, TooltipTrigger } from '@craft-agent/ui' +import { computeComposerCounts, shouldShowComposerCount } from '@/lib/composer-count' + +export interface ComposerCountIndicatorProps { + /** Current plain-text draft in the composer. */ + text: string +} + +/** + * ComposerCountIndicator — a subtle live word/character/line count for the + * composer draft. + * + * Renders `null` for an empty/whitespace-only draft so the empty composer stays + * clean. Shows the word count inline; the full breakdown (words / characters / + * lines) is in the hover tooltip. All values are exposed as `data-*` attributes + * for e2e assertions. + */ +export function ComposerCountIndicator({ text }: ComposerCountIndicatorProps) { + const { t } = useTranslation() + + const counts = React.useMemo(() => computeComposerCounts(text), [text]) + + if (!shouldShowComposerCount(text)) return null + + const wordsLabel = t('chat.composerWords', { count: counts.words }) + const charactersLabel = t('chat.composerCharacters', { count: counts.characters }) + const linesLabel = t('chat.composerLines', { count: counts.lines }) + + return ( + + + + {wordsLabel} + + + +
+ {wordsLabel} + {charactersLabel} + {linesLabel} +
+
+
+ ) +} diff --git a/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx b/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx index 8761b1e2c..c5acfce3a 100644 --- a/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx +++ b/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx @@ -77,6 +77,7 @@ import { resolveEffectiveConnectionSlug } from '@config/llm-connections'; import { useOptionalAppShellContext } from '@/context/AppShellContext'; import { EditPopover, getEditConfig } from '@/components/ui/EditPopover'; import { FreeFormInputContextBadge } from './FreeFormInputContextBadge'; +import { ComposerCountIndicator } from './ComposerCountIndicator'; import type { AvailableSlashCommand, FileAttachment, @@ -2742,6 +2743,9 @@ export function FreeFormInput({ )} + {/* 3. Draft word/character count - hidden in compact mode and when empty */} + {!compactMode && } + {/* Spacer — or the voice recording bar while dictating */} {voice.isActive ? ( { + it('returns zeros for an empty string', () => { + expect(computeComposerCounts('')).toEqual({ words: 0, characters: 0, lines: 0 }) + }) + + it('treats whitespace-only text as zero words', () => { + expect(computeComposerCounts(' \n\t ')).toMatchObject({ words: 0 }) + }) + + it('counts a single word', () => { + expect(computeComposerCounts('hello')).toEqual({ words: 1, characters: 5, lines: 1 }) + }) + + it('counts multiple words and includes whitespace in the character count', () => { + expect(computeComposerCounts('hello world')).toEqual({ + words: 2, + characters: 11, + lines: 1, + }) + }) + + it('collapses runs of internal whitespace when counting words', () => { + expect(computeComposerCounts(' hello there\tworld ')).toMatchObject({ words: 3 }) + }) + + it('ignores leading/trailing whitespace for word count but keeps it for characters', () => { + const counts = computeComposerCounts(' hi ') + expect(counts.words).toBe(1) + expect(counts.characters).toBe(6) + }) + + it('counts newline-delimited lines', () => { + expect(computeComposerCounts('a\nb\nc')).toMatchObject({ lines: 3 }) + expect(computeComposerCounts('a\n')).toMatchObject({ lines: 2 }) + }) + + it('counts astral characters (emoji) as one character each', () => { + // Two emoji + a space => 3 code points, 2 "words". + expect(computeComposerCounts('😀 🎉')).toEqual({ words: 2, characters: 3, lines: 1 }) + }) +}) + +describe('shouldShowComposerCount', () => { + it('is false for empty or whitespace-only drafts', () => { + expect(shouldShowComposerCount('')).toBe(false) + expect(shouldShowComposerCount(' \n\t')).toBe(false) + }) + + it('is true once there is any non-whitespace content', () => { + expect(shouldShowComposerCount('x')).toBe(true) + expect(shouldShowComposerCount(' hi ')).toBe(true) + }) +}) diff --git a/apps/electron/src/renderer/lib/composer-count.ts b/apps/electron/src/renderer/lib/composer-count.ts new file mode 100644 index 000000000..55d195c68 --- /dev/null +++ b/apps/electron/src/renderer/lib/composer-count.ts @@ -0,0 +1,44 @@ +/** + * Pure helpers for the composer's live draft count indicator. + * + * Kept DOM-free so the counting semantics (Unicode-aware character count, + * whitespace-collapsing word count, newline-based line count) can be locked + * down with unit tests. The composer stores its draft as a plain-text string + * (see `coerceInputText`), so these operate directly on that value. + */ + +export interface ComposerCounts { + /** Whitespace-separated word tokens (0 for empty / whitespace-only text). */ + words: number + /** Unicode code points, so astral characters (emoji) count as one each. */ + characters: number + /** Newline-delimited lines (0 for empty text, otherwise `\n` count + 1). */ + lines: number +} + +/** + * Derive the word / character / line counts for a draft. + * + * - `characters` counts Unicode code points (`[...text].length`) rather than + * UTF-16 units, so a single emoji counts as one character. + * - `words` splits the trimmed text on any run of whitespace; empty or + * whitespace-only input yields `0`. + * - `lines` is the number of `\n`-delimited lines; empty input yields `0`. + */ +export function computeComposerCounts(text: string): ComposerCounts { + const characters = [...text].length + const trimmed = text.trim() + const words = trimmed === '' ? 0 : trimmed.split(/\s+/u).length + const lines = text === '' ? 0 : text.split('\n').length + return { words, characters, lines } +} + +/** + * Whether the count indicator should be shown for a given draft. + * + * Only when there is at least one non-whitespace character — an empty (or + * purely whitespace) composer stays clean. + */ +export function shouldShowComposerCount(text: string): boolean { + return text.trim().length > 0 +} diff --git a/docs/loop/feature-ledger.md b/docs/loop/feature-ledger.md index 9ea31696e..95d8cb26a 100644 --- a/docs/loop/feature-ledger.md +++ b/docs/loop/feature-ledger.md @@ -33,6 +33,7 @@ log, not the system of record. | slug | title | source | feasibility | status | issue | pr | branch | updated | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| composer-word-count | Live word/character/line count indicator in the chat composer | Codex/Claude desktop composer counters + editor status bars | frontend-only | pr-open | [#60](https://github.com/modelstudioai/openwork/issues/60) | [#61](https://github.com/modelstudioai/openwork/pull/61) | loop/composer-word-count | 2026-07-06 | Pure `computeComposerCounts` module (+unit tests) → `ComposerCountIndicator` in the composer toolbar, derived from the existing `input` string; hidden when empty / in compact mode. 3 new plural-neutral i18n keys (`chat.composer{Words,Characters,Lines}`) in all 7 locales. typecheck:all zero-delta (11 pre-existing baseline errors, none in touched files); unit tests 10/10; i18n parity OK; renderer build ✅; assertion transpiles. **CDP could not run locally**: the e2e build 403s on the Electron binary download and the `libsignal` WhatsApp-worker dep (same egress block as prior loop PRs); `composer-count.assert.ts` included for CI/reviewer. | | shortcuts-search | Search box on the Settings → Keyboard Shortcuts page | Codex "keypress search" + VS Code / Claude keybindings search; mirrors OpenWork's own settings-navigator search (#40) | frontend-only | pr-open | [#58](https://github.com/modelstudioai/openwork/issues/58) | [#59](https://github.com/modelstudioai/openwork/pull/59) | loop/shortcuts-search | 2026-07-05 | Pure renderer view over the action registry (`actionsByCategory` + `getHotkeyDisplay`); filters rows by action label **and** rendered key tokens ("keypress search"), hides empty sections, shows empty state. Reuses `common.search`/`noResultsFound`/`clear` (**zero** new i18n keys). Added `data-testid="settings-item-"` to settings-nav items for e2e navigation. typecheck/renderer-build/i18n-parity zero-delta vs main; touched-area tests pass. CDP assertion written (app launch egress-blocked locally, same as prior rounds). | | command-palette-recents | "Recently used" section in the Command Palette (⌘K) | VS Code ⌘⇧P / Raycast / Linear ⌘K / Claude Code Desktop command menu | frontend-only | pr-open | [#56](https://github.com/modelstudioai/openwork/issues/56) | [#57](https://github.com/modelstudioai/openwork/pull/57) | loop/command-palette-recents | 2026-07-05 | Palette (#42) had no memory of what you run. Adds a persisted recents list (`localStorage` `command-palette-recents`, dedupe/cap-6, newest-first) surfaced as a top "Recently used" group, hidden while searching. Pure `pushRecent` module + 6 unit tests (pass). One new i18n key `commands.recent` × 7 locales. typecheck/`bun test` zero-delta vs main (56 baseline fails on both, 0 new); renderer build ✅. **CDP could not run locally** (same env block as prior rounds: egress 403s Electron binary + `libsignal`/WhatsApp-worker dep) — assertion included for CI/reviewer. | | thinking-menu-shortcut | Keyboard shortcut (⌘⇧E) to open the composer's thinking (effort) menu | Claude Code Desktop effort-menu shortcut (⌘⇧E); explicit follow-up flagged in #44 | frontend-only | pr-open | [#54](https://github.com/modelstudioai/openwork/issues/54) | [#55](https://github.com/modelstudioai/openwork/pull/55) | loop/thinking-menu-shortcut | 2026-07-04 | New `chat.openThinkingMenu` action (`mod+shift+e`) → scoped `craft:open-composer-menu` event → `FreeFormInput` opens its already-controlled `thinkingDropdownOpen`. Mirrors `craft:focus-input`/`shouldHandleScopedInputEvent` for multi-panel safety. Auto-appears in Command Palette + Shortcuts reference. 1 new i18n key across 7 locales. Avoids ⌘⇧I (reserved for DevTools) / ⌘⇧M (permission mode already has Shift+Tab). typecheck zero-delta (11 pre-existing); `bun test` zero-delta (56 identical failures, diffed vs main); i18n parity OK; renderer build ✅; assertion transpiles ✅. **CDP blocked locally** (same egress limit: `libsignal` GitHub dep + Electron binary 403). | diff --git a/e2e/assertions/composer-count.assert.ts b/e2e/assertions/composer-count.assert.ts new file mode 100644 index 000000000..32d7329e0 --- /dev/null +++ b/e2e/assertions/composer-count.assert.ts @@ -0,0 +1,132 @@ +/** + * Feature assertion: the composer's live word / character count indicator. + * + * Drives the real built app over CDP entirely in the draft (no-session) state — + * no seeded conversation and no backend — through the full path: + * empty composer shows no count indicator → typing real text makes it appear + * with the correct word/character counts → appending updates the counts live → + * clearing the draft hides the indicator again. + * + * Asserting the indicator is *absent* while empty and *present with correct + * counts* after typing (and gone again after clearing) proves it reflects the + * actual draft content reactively, not merely that a static element renders. + */ + +import type { Assertion } from '../runner'; + +/** The composer's contenteditable carries this stable tutorial hook. */ +const EDITOR = '[data-tutorial="chat-input"]'; +const COUNT = '[data-testid="composer-count"]'; + +/** Read a numeric data-* attribute off the count indicator (or null if absent). */ +const readCountAttr = (attr: string) => + `(() => { const el = document.querySelector(${JSON.stringify( + COUNT, + )}); return el ? el.getAttribute(${JSON.stringify(attr)}) : null; })()`; + +/** Focus the composer editor and place a collapsed caret at the end of its content. */ +const FOCUS_CARET_END_EXPR = `(() => { + const el = document.querySelector(${JSON.stringify(EDITOR)}); + if (!el) return false; + el.focus(); + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + return true; +})()`; + +/** Select the composer's entire content and delete it (fires a real input event). */ +const CLEAR_EDITOR_EXPR = `(() => { + const el = document.querySelector(${JSON.stringify(EDITOR)}); + if (!el) return false; + el.focus(); + document.execCommand('selectAll'); + document.execCommand('delete'); + return true; +})()`; + +const assertion: Assertion = { + name: 'composer shows a live word/character count that tracks the draft', + async run(app) { + const { session } = app; + + // App fully mounted. + await session.waitForFunction( + '!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0', + { timeoutMs: 30000, message: 'React UI did not mount' }, + ); + + // Reach the ready AppShell (not onboarding / workspace picker) — the same + // stable, non-localized anchor the other composer assertions wait on. + await session.waitForSelector('[aria-label="Craft menu"]', { + timeoutMs: 30000, + message: 'app did not reach the ready AppShell state', + }); + + // The composer's contenteditable renders. + await session.waitForSelector(EDITOR, { + timeoutMs: 20000, + message: 'composer editor did not render', + }); + + // 1. While the draft is empty, the count indicator is absent. + if (await session.evaluate(`!!document.querySelector(${JSON.stringify(COUNT)})`)) { + throw new Error('count indicator was present for an empty composer'); + } + + // 2. Type real text and assert the indicator appears with the right counts. + if (!(await session.evaluate(FOCUS_CARET_END_EXPR))) { + throw new Error('could not focus the composer editor'); + } + await session.send('Input.insertText', { text: 'hello world' }); + + await session.waitForFunction(`document.querySelector(${JSON.stringify(COUNT)})`, { + timeoutMs: 8000, + message: 'count indicator did not appear after typing', + }); + + const words1 = await session.evaluate(readCountAttr('data-word-count')); + const chars1 = await session.evaluate(readCountAttr('data-char-count')); + if (words1 !== '2') { + throw new Error(`expected 2 words after typing "hello world", saw ${JSON.stringify(words1)}`); + } + if (chars1 !== '11') { + throw new Error( + `expected 11 characters after typing "hello world", saw ${JSON.stringify(chars1)}`, + ); + } + + // 3. Appending more text updates the counts live. + if (!(await session.evaluate(FOCUS_CARET_END_EXPR))) { + throw new Error('could not re-focus the composer editor before appending'); + } + await session.send('Input.insertText', { text: ' again' }); + + await session.waitForFunction( + `(() => { const el = document.querySelector(${JSON.stringify( + COUNT, + )}); return el && el.getAttribute('data-word-count') === '3'; })()`, + { timeoutMs: 8000, message: 'word count did not update to 3 after appending text' }, + ); + const chars2 = await session.evaluate(readCountAttr('data-char-count')); + if (chars2 !== '17') { + throw new Error( + `expected 17 characters after appending " again", saw ${JSON.stringify(chars2)}`, + ); + } + + // 4. Clearing the draft hides the indicator again. + if (!(await session.evaluate(CLEAR_EDITOR_EXPR))) { + throw new Error('could not clear the composer editor'); + } + await session.waitForFunction( + `!document.querySelector(${JSON.stringify(COUNT)})`, + { timeoutMs: 8000, message: 'count indicator did not disappear after clearing the draft' }, + ); + }, +}; + +export default assertion; diff --git a/packages/shared/src/i18n/locales/de.json b/packages/shared/src/i18n/locales/de.json index 8e64b3914..13e498969 100644 --- a/packages/shared/src/i18n/locales/de.json +++ b/packages/shared/src/i18n/locales/de.json @@ -128,6 +128,9 @@ "chat.clickForTaskActions": "Für Aufgabenaktionen klicken", "chat.clickToOpen": "Klicken um {{name}} zu öffnen", "chat.collapseComposer": "Editor verkleinern", + "chat.composerCharacters": "Zeichen: {{count}}", + "chat.composerLines": "Zeilen: {{count}}", + "chat.composerWords": "Wörter: {{count}}", "chat.connectionDefault": "Standard dieser Verbindung", "chat.connectionUnavailable": "Verbindung nicht verfügbar", "chat.connectionUnavailableDescription": "Die von dieser Sitzung verwendete Verbindung wurde entfernt. Erstellen Sie eine neue Sitzung, um fortzufahren.", diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json index 7fe30a7b6..c12137fa1 100644 --- a/packages/shared/src/i18n/locales/en.json +++ b/packages/shared/src/i18n/locales/en.json @@ -128,6 +128,9 @@ "chat.clickForTaskActions": "Click for task actions", "chat.clickToOpen": "Click to open {{name}}", "chat.collapseComposer": "Collapse composer", + "chat.composerCharacters": "Characters: {{count}}", + "chat.composerLines": "Lines: {{count}}", + "chat.composerWords": "Words: {{count}}", "chat.connectionDefault": "Connection default", "chat.connectionUnavailable": "Connection Unavailable", "chat.connectionUnavailableDescription": "The connection used by this session has been removed. Create a new session to continue.", diff --git a/packages/shared/src/i18n/locales/es.json b/packages/shared/src/i18n/locales/es.json index 232090f60..6948b2233 100644 --- a/packages/shared/src/i18n/locales/es.json +++ b/packages/shared/src/i18n/locales/es.json @@ -128,6 +128,9 @@ "chat.clickForTaskActions": "Haz clic para acciones de tarea", "chat.clickToOpen": "Clic para abrir {{name}}", "chat.collapseComposer": "Contraer el editor", + "chat.composerCharacters": "Caracteres: {{count}}", + "chat.composerLines": "Líneas: {{count}}", + "chat.composerWords": "Palabras: {{count}}", "chat.connectionDefault": "Predeterminado de esta conexión", "chat.connectionUnavailable": "Conexión no disponible", "chat.connectionUnavailableDescription": "La conexión usada por esta sesión se ha eliminado. Crea una nueva sesión para continuar.", diff --git a/packages/shared/src/i18n/locales/hu.json b/packages/shared/src/i18n/locales/hu.json index d2ba42358..ba7f4154d 100644 --- a/packages/shared/src/i18n/locales/hu.json +++ b/packages/shared/src/i18n/locales/hu.json @@ -128,6 +128,9 @@ "chat.clickForTaskActions": "Kattints a feladatműveletekért", "chat.clickToOpen": "Kattints a(z) {{name}} megnyitásához", "chat.collapseComposer": "Szerkesztő összecsukása", + "chat.composerCharacters": "Karakterek: {{count}}", + "chat.composerLines": "Sorok: {{count}}", + "chat.composerWords": "Szavak: {{count}}", "chat.connectionDefault": "A kapcsolat alapértelmezése", "chat.connectionUnavailable": "Kapcsolat nem érhető el", "chat.connectionUnavailableDescription": "A munkamenet által használt kapcsolatot eltávolították. A folytatáshoz hozz létre egy új munkamenetet.", diff --git a/packages/shared/src/i18n/locales/ja.json b/packages/shared/src/i18n/locales/ja.json index cdbc55eba..f95e6b40d 100644 --- a/packages/shared/src/i18n/locales/ja.json +++ b/packages/shared/src/i18n/locales/ja.json @@ -128,6 +128,9 @@ "chat.clickForTaskActions": "クリックしてタスクアクションを表示", "chat.clickToOpen": "クリックして{{name}}を開く", "chat.collapseComposer": "エディターを縮小", + "chat.composerCharacters": "文字: {{count}}", + "chat.composerLines": "行: {{count}}", + "chat.composerWords": "単語: {{count}}", "chat.connectionDefault": "この接続のデフォルト", "chat.connectionUnavailable": "接続を利用できません", "chat.connectionUnavailableDescription": "このセッションで使用していた接続は削除されました。続行するには新しいセッションを作成してください。", diff --git a/packages/shared/src/i18n/locales/pl.json b/packages/shared/src/i18n/locales/pl.json index 82a98af76..10db689fb 100644 --- a/packages/shared/src/i18n/locales/pl.json +++ b/packages/shared/src/i18n/locales/pl.json @@ -128,6 +128,9 @@ "chat.clickForTaskActions": "Kliknij, aby zobaczyć akcje zadania", "chat.clickToOpen": "Kliknij, aby otworzyć {{name}}", "chat.collapseComposer": "Zwiń edytor", + "chat.composerCharacters": "Znaki: {{count}}", + "chat.composerLines": "Wiersze: {{count}}", + "chat.composerWords": "Słowa: {{count}}", "chat.connectionDefault": "Domyślne dla tego połączenia", "chat.connectionUnavailable": "Połączenie niedostępne", "chat.connectionUnavailableDescription": "Połączenie używane przez tę sesję zostało usunięte. Utwórz nową sesję, aby kontynuować.", diff --git a/packages/shared/src/i18n/locales/zh-Hans.json b/packages/shared/src/i18n/locales/zh-Hans.json index c7fad09b2..1fea1373c 100644 --- a/packages/shared/src/i18n/locales/zh-Hans.json +++ b/packages/shared/src/i18n/locales/zh-Hans.json @@ -128,6 +128,9 @@ "chat.clickForTaskActions": "点击查看任务操作", "chat.clickToOpen": "点击打开 {{name}}", "chat.collapseComposer": "收起输入框", + "chat.composerCharacters": "字符:{{count}}", + "chat.composerLines": "行数:{{count}}", + "chat.composerWords": "词数:{{count}}", "chat.connectionDefault": "此连接的默认值", "chat.connectionUnavailable": "连接不可用", "chat.connectionUnavailableDescription": "此会话使用的连接已被删除。请创建一个新会话以继续。",