Skip to content

Commit c098c3f

Browse files
committed
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
1 parent 26f609c commit c098c3f

13 files changed

Lines changed: 321 additions & 1 deletion

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as React from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { Tooltip, TooltipContent, TooltipTrigger } from '@craft-agent/ui'
4+
import { computeComposerCounts, shouldShowComposerCount } from '@/lib/composer-count'
5+
6+
export interface ComposerCountIndicatorProps {
7+
/** Current plain-text draft in the composer. */
8+
text: string
9+
}
10+
11+
/**
12+
* ComposerCountIndicator — a subtle live word/character/line count for the
13+
* composer draft.
14+
*
15+
* Renders `null` for an empty/whitespace-only draft so the empty composer stays
16+
* clean. Shows the word count inline; the full breakdown (words / characters /
17+
* lines) is in the hover tooltip. All values are exposed as `data-*` attributes
18+
* for e2e assertions.
19+
*/
20+
export function ComposerCountIndicator({ text }: ComposerCountIndicatorProps) {
21+
const { t } = useTranslation()
22+
23+
const counts = React.useMemo(() => computeComposerCounts(text), [text])
24+
25+
if (!shouldShowComposerCount(text)) return null
26+
27+
const wordsLabel = t('chat.composerWords', { count: counts.words })
28+
const charactersLabel = t('chat.composerCharacters', { count: counts.characters })
29+
const linesLabel = t('chat.composerLines', { count: counts.lines })
30+
31+
return (
32+
<Tooltip>
33+
<TooltipTrigger asChild>
34+
<span
35+
data-testid="composer-count"
36+
data-word-count={counts.words}
37+
data-char-count={counts.characters}
38+
data-line-count={counts.lines}
39+
aria-label={`${wordsLabel}, ${charactersLabel}, ${linesLabel}`}
40+
className="select-none tabular-nums text-[12px] text-muted-foreground px-1.5 shrink-0 whitespace-nowrap"
41+
>
42+
{wordsLabel}
43+
</span>
44+
</TooltipTrigger>
45+
<TooltipContent side="top">
46+
<div className="flex flex-col gap-0.5 text-[12px] tabular-nums">
47+
<span>{wordsLabel}</span>
48+
<span>{charactersLabel}</span>
49+
<span>{linesLabel}</span>
50+
</div>
51+
</TooltipContent>
52+
</Tooltip>
53+
)
54+
}

apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { resolveEffectiveConnectionSlug } from '@config/llm-connections';
7171
import { useOptionalAppShellContext } from '@/context/AppShellContext';
7272
import { EditPopover, getEditConfig } from '@/components/ui/EditPopover';
7373
import { FreeFormInputContextBadge } from './FreeFormInputContextBadge';
74+
import { ComposerCountIndicator } from './ComposerCountIndicator';
7475
import type {
7576
AvailableSlashCommand,
7677
FileAttachment,
@@ -2549,6 +2550,9 @@ export function FreeFormInput({
25492550
</div>
25502551
)}
25512552

2553+
{/* 3. Draft word/character count - hidden in compact mode and when empty */}
2554+
{!compactMode && <ComposerCountIndicator text={input} />}
2555+
25522556
{/* Spacer */}
25532557
<div className="flex-1" />
25542558

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { computeComposerCounts, shouldShowComposerCount } from '../composer-count'
3+
4+
describe('computeComposerCounts', () => {
5+
it('returns zeros for an empty string', () => {
6+
expect(computeComposerCounts('')).toEqual({ words: 0, characters: 0, lines: 0 })
7+
})
8+
9+
it('treats whitespace-only text as zero words', () => {
10+
expect(computeComposerCounts(' \n\t ')).toMatchObject({ words: 0 })
11+
})
12+
13+
it('counts a single word', () => {
14+
expect(computeComposerCounts('hello')).toEqual({ words: 1, characters: 5, lines: 1 })
15+
})
16+
17+
it('counts multiple words and includes whitespace in the character count', () => {
18+
expect(computeComposerCounts('hello world')).toEqual({
19+
words: 2,
20+
characters: 11,
21+
lines: 1,
22+
})
23+
})
24+
25+
it('collapses runs of internal whitespace when counting words', () => {
26+
expect(computeComposerCounts(' hello there\tworld ')).toMatchObject({ words: 3 })
27+
})
28+
29+
it('ignores leading/trailing whitespace for word count but keeps it for characters', () => {
30+
const counts = computeComposerCounts(' hi ')
31+
expect(counts.words).toBe(1)
32+
expect(counts.characters).toBe(6)
33+
})
34+
35+
it('counts newline-delimited lines', () => {
36+
expect(computeComposerCounts('a\nb\nc')).toMatchObject({ lines: 3 })
37+
expect(computeComposerCounts('a\n')).toMatchObject({ lines: 2 })
38+
})
39+
40+
it('counts astral characters (emoji) as one character each', () => {
41+
// Two emoji + a space => 3 code points, 2 "words".
42+
expect(computeComposerCounts('😀 🎉')).toEqual({ words: 2, characters: 3, lines: 1 })
43+
})
44+
})
45+
46+
describe('shouldShowComposerCount', () => {
47+
it('is false for empty or whitespace-only drafts', () => {
48+
expect(shouldShowComposerCount('')).toBe(false)
49+
expect(shouldShowComposerCount(' \n\t')).toBe(false)
50+
})
51+
52+
it('is true once there is any non-whitespace content', () => {
53+
expect(shouldShowComposerCount('x')).toBe(true)
54+
expect(shouldShowComposerCount(' hi ')).toBe(true)
55+
})
56+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Pure helpers for the composer's live draft count indicator.
3+
*
4+
* Kept DOM-free so the counting semantics (Unicode-aware character count,
5+
* whitespace-collapsing word count, newline-based line count) can be locked
6+
* down with unit tests. The composer stores its draft as a plain-text string
7+
* (see `coerceInputText`), so these operate directly on that value.
8+
*/
9+
10+
export interface ComposerCounts {
11+
/** Whitespace-separated word tokens (0 for empty / whitespace-only text). */
12+
words: number
13+
/** Unicode code points, so astral characters (emoji) count as one each. */
14+
characters: number
15+
/** Newline-delimited lines (0 for empty text, otherwise `\n` count + 1). */
16+
lines: number
17+
}
18+
19+
/**
20+
* Derive the word / character / line counts for a draft.
21+
*
22+
* - `characters` counts Unicode code points (`[...text].length`) rather than
23+
* UTF-16 units, so a single emoji counts as one character.
24+
* - `words` splits the trimmed text on any run of whitespace; empty or
25+
* whitespace-only input yields `0`.
26+
* - `lines` is the number of `\n`-delimited lines; empty input yields `0`.
27+
*/
28+
export function computeComposerCounts(text: string): ComposerCounts {
29+
const characters = [...text].length
30+
const trimmed = text.trim()
31+
const words = trimmed === '' ? 0 : trimmed.split(/\s+/u).length
32+
const lines = text === '' ? 0 : text.split('\n').length
33+
return { words, characters, lines }
34+
}
35+
36+
/**
37+
* Whether the count indicator should be shown for a given draft.
38+
*
39+
* Only when there is at least one non-whitespace character — an empty (or
40+
* purely whitespace) composer stays clean.
41+
*/
42+
export function shouldShowComposerCount(text: string): boolean {
43+
return text.trim().length > 0
44+
}

docs/loop/feature-ledger.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ log, not the system of record.
3232

3333
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3434
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
35-
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | pr-open | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-02 | `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). typecheck/`bun test` zero-delta vs main (3578 pass/56 fail on both); renderer build ✅. **CDP could not run locally**: this sandbox's egress policy 403s the Electron binary download and the `libsignal` GitHub dep (WhatsApp worker), so the app can't be built/launched here — assertion included for CI/reviewer. |
35+
| 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. |
36+
| shortcuts-search | Search box on the Settings → Keyboard Shortcuts page | Codex keypress-search + VS Code / Claude keybindings search | 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-06 | Reconciled from GitHub. Filters shortcut rows by label + key token; reuses `common.*` (zero new keys). |
37+
| command-palette-recents | "Recently used" group in the Command Palette (⌘K) | VS Code / Raycast / Linear / Claude recents | 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-06 | Reconciled from GitHub. localStorage-persisted recents; one new key `commands.recent`. |
38+
| thinking-menu-shortcut | ⌘⇧E keyboard shortcut to open the composer thinking menu | Claude Code Desktop effort-menu shortcut (⌘⇧E) | 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-06 | Reconciled from GitHub. Bridges action registry → composer dropdown via scoped custom event. |
39+
| composer-prompt-history | Up/Down prompt history recall in the composer | Codex composer ↑ recall + shell history | frontend-only | pr-open | [#52](https://github.com/modelstudioai/openwork/issues/52) | [#53](https://github.com/modelstudioai/openwork/pull/53) | loop/composer-prompt-history | 2026-07-06 | Reconciled from GitHub. localStorage `craft-prompt-history`; pure nav module + unit tests. |
40+
| reduce-motion | "Reduce motion" accessibility toggle in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-06 | Reconciled from GitHub. Root `MotionConfig` + CSS guard; localStorage `craft-reduce-motion`. |
41+
| composer-expand | Expand/collapse (maximize) toggle for the chat composer | Claude/ChatGPT/Codex desktop maximize composer | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-06 | Reconciled from GitHub. Toggles a tall minHeight/maxHeight on the editable area. |
42+
| scroll-to-bottom | "Jump to latest" scroll-to-bottom button in the transcript | Claude/ChatGPT/Codex desktop jump-to-latest | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-06 | Reconciled from GitHub. Floating chevron over existing sticky-bottom scroll state; adds `seed()` harness hook. |
43+
| app-search-cmdf-bug | Cmd+F (`app.search`) shortcut doesn't activate session search | OpenWork bug report | frontend-only | blocked | [#43](https://github.com/modelstudioai/openwork/issues/43) ||| 2026-07-06 | Bug, not a feature. Open, no PR/branch yet. Needs a seeded-session repro the headless harness can't produce; left for a dedicated run. |
44+
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-06 | Merged into `main` (2026-07-02). `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). |
3645
| command-palette | Global command palette (⌘K/Ctrl+K) to search & run any action | Claude Code Desktop ⌘K / VS Code & Codex ⌘⇧P / Linear ⌘K | frontend-only | merged | [#41](https://github.com/modelstudioai/openwork/issues/41) | [#42](https://github.com/modelstudioai/openwork/pull/42) | loop/command-palette | 2026-07-02 | Merged into `main`. Reuses action registry `execute()` + cmdk primitives; zero new i18n keys. CDP e2e 2/2 pass. typecheck/test +0 vs main. |
3746
| settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | merged | [#39](https://github.com/modelstudioai/openwork/issues/39) | [#40](https://github.com/modelstudioai/openwork/pull/40) | loop/settings-search | 2026-07-01 | Merged into `main`. Filters `SettingsNavigator` by title+description; reuses `common.search`/`common.noResultsFound` (no new locale keys). Also hardened `e2e/app.ts` teardown (per-launch profile dir + setsid process-group kill) so multiple CDP assertions run under headless xvfb. |
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Feature assertion: the composer's live word / character count indicator.
3+
*
4+
* Drives the real built app over CDP entirely in the draft (no-session) state —
5+
* no seeded conversation and no backend — through the full path:
6+
* empty composer shows no count indicator → typing real text makes it appear
7+
* with the correct word/character counts → appending updates the counts live →
8+
* clearing the draft hides the indicator again.
9+
*
10+
* Asserting the indicator is *absent* while empty and *present with correct
11+
* counts* after typing (and gone again after clearing) proves it reflects the
12+
* actual draft content reactively, not merely that a static element renders.
13+
*/
14+
15+
import type { Assertion } from '../runner';
16+
17+
/** The composer's contenteditable carries this stable tutorial hook. */
18+
const EDITOR = '[data-tutorial="chat-input"]';
19+
const COUNT = '[data-testid="composer-count"]';
20+
21+
/** Read a numeric data-* attribute off the count indicator (or null if absent). */
22+
const readCountAttr = (attr: string) =>
23+
`(() => { const el = document.querySelector(${JSON.stringify(
24+
COUNT,
25+
)}); return el ? el.getAttribute(${JSON.stringify(attr)}) : null; })()`;
26+
27+
/** Focus the composer editor and place a collapsed caret at the end of its content. */
28+
const FOCUS_CARET_END_EXPR = `(() => {
29+
const el = document.querySelector(${JSON.stringify(EDITOR)});
30+
if (!el) return false;
31+
el.focus();
32+
const range = document.createRange();
33+
range.selectNodeContents(el);
34+
range.collapse(false);
35+
const sel = window.getSelection();
36+
sel.removeAllRanges();
37+
sel.addRange(range);
38+
return true;
39+
})()`;
40+
41+
/** Select the composer's entire content and delete it (fires a real input event). */
42+
const CLEAR_EDITOR_EXPR = `(() => {
43+
const el = document.querySelector(${JSON.stringify(EDITOR)});
44+
if (!el) return false;
45+
el.focus();
46+
document.execCommand('selectAll');
47+
document.execCommand('delete');
48+
return true;
49+
})()`;
50+
51+
const assertion: Assertion = {
52+
name: 'composer shows a live word/character count that tracks the draft',
53+
async run(app) {
54+
const { session } = app;
55+
56+
// App fully mounted.
57+
await session.waitForFunction(
58+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
59+
{ timeoutMs: 30000, message: 'React UI did not mount' },
60+
);
61+
62+
// Reach the ready AppShell (not onboarding / workspace picker) — the same
63+
// stable, non-localized anchor the other composer assertions wait on.
64+
await session.waitForSelector('[aria-label="Craft menu"]', {
65+
timeoutMs: 30000,
66+
message: 'app did not reach the ready AppShell state',
67+
});
68+
69+
// The composer's contenteditable renders.
70+
await session.waitForSelector(EDITOR, {
71+
timeoutMs: 20000,
72+
message: 'composer editor did not render',
73+
});
74+
75+
// 1. While the draft is empty, the count indicator is absent.
76+
if (await session.evaluate<boolean>(`!!document.querySelector(${JSON.stringify(COUNT)})`)) {
77+
throw new Error('count indicator was present for an empty composer');
78+
}
79+
80+
// 2. Type real text and assert the indicator appears with the right counts.
81+
if (!(await session.evaluate<boolean>(FOCUS_CARET_END_EXPR))) {
82+
throw new Error('could not focus the composer editor');
83+
}
84+
await session.send('Input.insertText', { text: 'hello world' });
85+
86+
await session.waitForFunction(`document.querySelector(${JSON.stringify(COUNT)})`, {
87+
timeoutMs: 8000,
88+
message: 'count indicator did not appear after typing',
89+
});
90+
91+
const words1 = await session.evaluate<string | null>(readCountAttr('data-word-count'));
92+
const chars1 = await session.evaluate<string | null>(readCountAttr('data-char-count'));
93+
if (words1 !== '2') {
94+
throw new Error(`expected 2 words after typing "hello world", saw ${JSON.stringify(words1)}`);
95+
}
96+
if (chars1 !== '11') {
97+
throw new Error(
98+
`expected 11 characters after typing "hello world", saw ${JSON.stringify(chars1)}`,
99+
);
100+
}
101+
102+
// 3. Appending more text updates the counts live.
103+
if (!(await session.evaluate<boolean>(FOCUS_CARET_END_EXPR))) {
104+
throw new Error('could not re-focus the composer editor before appending');
105+
}
106+
await session.send('Input.insertText', { text: ' again' });
107+
108+
await session.waitForFunction(
109+
`(() => { const el = document.querySelector(${JSON.stringify(
110+
COUNT,
111+
)}); return el && el.getAttribute('data-word-count') === '3'; })()`,
112+
{ timeoutMs: 8000, message: 'word count did not update to 3 after appending text' },
113+
);
114+
const chars2 = await session.evaluate<string | null>(readCountAttr('data-char-count'));
115+
if (chars2 !== '17') {
116+
throw new Error(
117+
`expected 17 characters after appending " again", saw ${JSON.stringify(chars2)}`,
118+
);
119+
}
120+
121+
// 4. Clearing the draft hides the indicator again.
122+
if (!(await session.evaluate<boolean>(CLEAR_EDITOR_EXPR))) {
123+
throw new Error('could not clear the composer editor');
124+
}
125+
await session.waitForFunction(
126+
`!document.querySelector(${JSON.stringify(COUNT)})`,
127+
{ timeoutMs: 8000, message: 'count indicator did not disappear after clearing the draft' },
128+
);
129+
},
130+
};
131+
132+
export default assertion;

packages/shared/src/i18n/locales/de.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@
127127
"chat.clearDraft": "Entwurf löschen",
128128
"chat.clickForTaskActions": "Für Aufgabenaktionen klicken",
129129
"chat.clickToOpen": "Klicken um {{name}} zu öffnen",
130+
"chat.composerCharacters": "Zeichen: {{count}}",
131+
"chat.composerLines": "Zeilen: {{count}}",
132+
"chat.composerWords": "Wörter: {{count}}",
130133
"chat.connectionDefault": "Standard dieser Verbindung",
131134
"chat.connectionUnavailable": "Verbindung nicht verfügbar",
132135
"chat.connectionUnavailableDescription": "Die von dieser Sitzung verwendete Verbindung wurde entfernt. Erstellen Sie eine neue Sitzung, um fortzufahren.",

packages/shared/src/i18n/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@
127127
"chat.clearDraft": "Clear draft",
128128
"chat.clickForTaskActions": "Click for task actions",
129129
"chat.clickToOpen": "Click to open {{name}}",
130+
"chat.composerCharacters": "Characters: {{count}}",
131+
"chat.composerLines": "Lines: {{count}}",
132+
"chat.composerWords": "Words: {{count}}",
130133
"chat.connectionDefault": "Connection default",
131134
"chat.connectionUnavailable": "Connection Unavailable",
132135
"chat.connectionUnavailableDescription": "The connection used by this session has been removed. Create a new session to continue.",

0 commit comments

Comments
 (0)