Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid="composer-count"
data-word-count={counts.words}
data-char-count={counts.characters}
data-line-count={counts.lines}
aria-label={`${wordsLabel}, ${charactersLabel}, ${linesLabel}`}
className="select-none tabular-nums text-[12px] text-muted-foreground px-1.5 shrink-0 whitespace-nowrap"
>
{wordsLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<div className="flex flex-col gap-0.5 text-[12px] tabular-nums">
<span>{wordsLabel}</span>
<span>{charactersLabel}</span>
<span>{linesLabel}</span>
</div>
</TooltipContent>
</Tooltip>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,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,
Expand Down Expand Up @@ -2549,6 +2550,9 @@ export function FreeFormInput({
</div>
)}

{/* 3. Draft word/character count - hidden in compact mode and when empty */}
{!compactMode && <ComposerCountIndicator text={input} />}

{/* Spacer */}
<div className="flex-1" />

Expand Down
56 changes: 56 additions & 0 deletions apps/electron/src/renderer/lib/__tests__/composer-count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'bun:test'
import { computeComposerCounts, shouldShowComposerCount } from '../composer-count'

describe('computeComposerCounts', () => {
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)
})
})
44 changes: 44 additions & 0 deletions apps/electron/src/renderer/lib/composer-count.ts
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 10 additions & 4 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| reduce-motion | "Reduce motion" accessibility setting 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-03 | Renderer-only pref (localStorage) applied app-wide via `<MotionConfig reducedMotion>` + `data-reduce-motion` on `<html>` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion included; **could not run locally** (egress 403s Electron binary download). |
| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude/ChatGPT/Codex desktop composer maximize | 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-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. |
| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code / ChatGPT / Codex desktop | 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-02 | Opened by a prior run. Floating jump button in `ChatDisplay` + `seed()` harness hook. Awaiting review. |
| 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-03 | **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). |
| 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 | 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). |
| 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`. |
| 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. |
| 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. |
| 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`. |
| 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. |
| 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. |
| 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. |
| 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). |
| 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. |
| 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. |
132 changes: 132 additions & 0 deletions e2e/assertions/composer-count.assert.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(`!!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<boolean>(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<string | null>(readCountAttr('data-word-count'));
const chars1 = await session.evaluate<string | null>(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<boolean>(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<string | null>(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<boolean>(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;
Loading