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 @@ -75,6 +75,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 @@ -2598,6 +2599,11 @@ export function FreeFormInput({
</div>
)}

{/* 3. Draft word/character count - hidden in compact mode, when empty, and while dictating */}
{!compactMode && !voice.isActive && (
<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
}
13 changes: 9 additions & 4 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| copy-message | "Copy" (copy-as-Markdown) action on assistant/agent responses | Codex desktop "Copy as Markdown" (openai/codex #2880, #17241) + Claude/ChatGPT desktop per-message copy | frontend-only | pr-open | [#70](https://github.com/modelstudioai/openwork/issues/70) | [#71](https://github.com/modelstudioai/openwork/pull/71) | loop/copy-message | 2026-07-08 | User messages + code blocks were already copyable, but full assistant responses had only a pop-out button — no copy. Extracted `AssistantMessage` component in `ChatDisplay.tsx` (owns `copied` state, mirrors the existing `ErrorMessage` extraction) with a hover copy button next to pop-out; copies raw `message.content` via `navigator.clipboard.writeText`, 2s "copied" check state, `toast.copyFailed` on error. **Zero new i18n keys** (reuses `common.copy`/`common.copied`/`toast.copyFailed`). Added a general `seed` hook to the e2e harness (`app.ts`/`runner.ts`) so assertions can pre-seed an on-disk session the embedded `SessionManager` loads on boot (no backend). typecheck:all zero-delta; `bun test` 56-failure set byte-identical to main; renderer build ✅; i18n parity ✅ (1544 keys); eslint 0 errors. CDP assertion (`copy-message.assert.ts`) seeds a user+assistant session, opens it, clicks the copy button, and asserts the exact response markdown reaches the (stubbed) clipboard + the button enters its copied state; **could not run locally** (org egress policy 403s the Electron binary download — same block as prior rounds). |
| 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 setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | merged | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-08 | **Merged** into `main` (2026-07-06). 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. |
| 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-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. |
Loading