Skip to content
Merged
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 @@ -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,
Expand Down Expand Up @@ -2742,6 +2743,9 @@ export function FreeFormInput({
</div>
)}

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

{/* Spacer — or the voice recording bar while dictating */}
{voice.isActive ? (
<VoiceRecordingBar
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
}
1 change: 1 addition & 0 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>"` 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). |
Expand Down
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;
3 changes: 3 additions & 0 deletions packages/shared/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/i18n/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "このセッションで使用していた接続は削除されました。続行するには新しいセッションを作成してください。",
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/i18n/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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ć.",
Expand Down
Loading