diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index 568e33e8..30461aea 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -38,6 +38,13 @@ const TOOL_ICONS: Record = { const WRITER_PREVIEW_MAX_LINES = 120 const WRITER_PREVIEW_MAX_CHARS = 30000 +type ContentStats = { + lines: number + chars: number + visibleLines?: number + windowed?: boolean +} + export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput }: Props) { const isPlanTool = isExitPlanModeTool(toolName) const [expanded, setExpanded] = useState(isPlanTool) @@ -58,6 +65,11 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu const stoppedSummary = status === 'stopped' && !result ? t('tool.stopped') : '' + const liveStats = useMemo( + () => getToolContentStats(toolName, obj, isPending ? partialInput : undefined), + [isPending, obj, partialInput, toolName], + ) + const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : '' const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t]) const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t]) @@ -108,9 +120,20 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu )} {pendingSummary ? ( - + ) : stoppedSummary ? ( @@ -127,6 +150,10 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu > {outputSummary} + ) : liveStatsSummary ? ( + + {liveStatsSummary} + ) : null} {result?.isError && ( error @@ -324,7 +351,7 @@ function renderDetails( if (partialInput) { if (toolName === 'Write') { const writerContent = extractPartialJsonStringField(partialInput, 'content') - if (writerContent) { + if (writerContent !== null) { return renderWriterPreview(writerContent, t) } } @@ -420,12 +447,99 @@ function extractPartialJsonStringField(source: string, field: string): string | return value } +function getToolContentStats( + toolName: string, + obj: Record, + partialInput?: string, +): ContentStats | null { + const content = getToolContentForStats(toolName, obj, partialInput) + return content === null ? null : countContentStats(content) +} + +function getToolContentForStats( + toolName: string, + obj: Record, + partialInput?: string, +): string | null { + if (toolName === 'Write') { + if (typeof obj.content === 'string') return obj.content + return partialInput ? extractPartialJsonStringField(partialInput, 'content') : null + } + + if (toolName === 'Edit') { + if (typeof obj.new_string === 'string') return obj.new_string + return partialInput ? extractPartialJsonStringField(partialInput, 'new_string') : null + } + + if (toolName === 'MultiEdit' && Array.isArray(obj.edits)) { + const replacements = obj.edits + .map((edit) => ( + edit && typeof edit === 'object' && typeof (edit as Record).new_string === 'string' + ? (edit as Record).new_string + : '' + )) + .filter(Boolean) + return replacements.length > 0 ? replacements.join('\n') : null + } + + return null +} + +function countContentStats(content: string): ContentStats { + return { + lines: content.length === 0 ? 0 : content.split('\n').length, + chars: content.length, + } +} + +function formatContentStats( + stats: ContentStats, + t?: (key: TranslationKey, params?: Record) => string, +): string { + const chars = formatCharCount(stats.chars, t) + if (stats.windowed && typeof stats.visibleLines === 'number' && stats.visibleLines < stats.lines) { + return t?.('tool.contentStatsLatest', { + visible: formatCount(stats.visibleLines), + total: formatCount(stats.lines), + chars, + }) ?? `Latest ${formatCount(stats.visibleLines)} / ${formatCount(stats.lines)} lines · ${chars}` + } + + return t?.('tool.contentStats', { + lines: formatLineCount(stats.lines, t), + chars, + }) ?? `${formatLineCount(stats.lines, t)} · ${chars}` +} + +function formatLineCount( + count: number, + t?: (key: TranslationKey, params?: Record) => string, +): string { + return count === 1 + ? (t?.('tool.lineCountSingular', { count: formatCount(count) }) ?? `${formatCount(count)} line`) + : (t?.('tool.lineCountPlural', { count: formatCount(count) }) ?? `${formatCount(count)} lines`) +} + +function formatCharCount( + count: number, + t?: (key: TranslationKey, params?: Record) => string, +): string { + return count === 1 + ? (t?.('tool.charCountSingular', { count: formatCount(count) }) ?? `${formatCount(count)} char`) + : (t?.('tool.charCountPlural', { count: formatCount(count) }) ?? `${formatCount(count)} chars`) +} + +function formatCount(count: number): string { + return new Intl.NumberFormat().format(count) +} + function renderWriterPreview( content: string, t?: (key: TranslationKey, params?: Record) => string, ) { - const lines = content.split('\n') - const totalLines = lines.length + const contentStats = countContentStats(content) + const lines = content.length === 0 ? [] : content.split('\n') + const totalLines = contentStats.lines const visibleLines = lines.length > WRITER_PREVIEW_MAX_LINES ? lines.slice(-WRITER_PREVIEW_MAX_LINES) : lines @@ -436,16 +550,21 @@ function renderWriterPreview( } const lineWindowed = totalLines > visibleLines.length const isWindowed = lineWindowed || charTruncated + const visibleLineCount = visibleContent.length === 0 ? 0 : visibleContent.split('\n').length + const statsSummary = formatContentStats({ + lines: totalLines, + chars: contentStats.chars, + visibleLines: visibleLineCount, + windowed: isWindowed, + }, t) return (
{t?.('tool.writerPreview') ?? 'Writer'} - {isWindowed ? ( - - {t?.('tool.writerPreviewLatest', { visible: visibleLines.length, total: totalLines }) ?? `Showing latest ${visibleLines.length} of ${totalLines} lines`} - - ) : null} + + {statsSummary} +
         {visibleContent}
diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx
index 9f825c21..2a88f542 100644
--- a/desktop/src/components/chat/chatBlocks.test.tsx
+++ b/desktop/src/components/chat/chatBlocks.test.tsx
@@ -109,6 +109,22 @@ describe('chat blocks', () => {
     expect(container.textContent).toContain('Generating content')
   })
 
+  it('shows pending Write line and character progress in the collapsed header', () => {
+    const { container } = render(
+      ,
+    )
+
+    expect(container.textContent).toContain('Generating content')
+    expect(container.textContent).toContain('2 lines')
+    expect(container.textContent).toContain('10 chars')
+    expect(container.textContent).not.toContain('latest')
+  })
+
   it('expands pending Write tool calls into a live writer preview instead of raw JSON', () => {
     const { container } = render(
        {
     expect(container.textContent).not.toContain('"content"')
   })
 
+  it('shows non-windowed Writer preview stats before the 120-line limit', () => {
+    const { container } = render(
+      ,
+    )
+
+    fireEvent.click(screen.getByRole('button'))
+
+    expect(container.textContent).toContain('Writer')
+    expect(container.textContent).toContain('3 lines')
+    expect(container.textContent).toContain('16 chars')
+    expect(container.textContent).not.toContain('latest')
+  })
+
+  it('shows pending Edit replacement character progress in the collapsed header', () => {
+    const { container } = render(
+      ,
+    )
+
+    expect(container.textContent).toContain('Preparing edit')
+    expect(container.textContent).toContain('1 line')
+    expect(container.textContent).toContain('18 chars')
+  })
+
   it('windows long pending Write previews to the latest content', () => {
     const lines = Array.from({ length: 180 }, (_, index) => `line-${index + 1}`)
     const escapedContent = lines.join('\\n')
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index a76d9fd5..a478b95e 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -1413,6 +1413,12 @@ export const en = {
   'tool.partialInput': 'Partial input',
   'tool.writerPreview': 'Writer',
   'tool.writerPreviewLatest': 'Showing latest {visible} of {total} lines',
+  'tool.contentStats': '{lines} · {chars}',
+  'tool.contentStatsLatest': 'Showing latest {visible} / {total} lines · {chars}',
+  'tool.lineCountSingular': '{count} line',
+  'tool.lineCountPlural': '{count} lines',
+  'tool.charCountSingular': '{count} char',
+  'tool.charCountPlural': '{count} chars',
   'tool.generatingContent': 'Generating content',
   'tool.preparingEdit': 'Preparing edit',
   'tool.preparingTool': 'Preparing tool',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index 92bc758d..5be1adf5 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -1415,6 +1415,12 @@ export const jp: Record = {
   'tool.partialInput': '部分的な入力',
   'tool.writerPreview': 'Writer',
   'tool.writerPreviewLatest': '{total} 行のうち最新の {visible} 行を表示中',
+  'tool.contentStats': '{lines} · {chars}',
+  'tool.contentStatsLatest': '最新 {visible} / {total} 行 · {chars}',
+  'tool.lineCountSingular': '{count} 行',
+  'tool.lineCountPlural': '{count} 行',
+  'tool.charCountSingular': '{count} 文字',
+  'tool.charCountPlural': '{count} 文字',
   'tool.generatingContent': 'コンテンツを生成中',
   'tool.preparingEdit': '編集を準備中',
   'tool.preparingTool': 'ツールを準備中',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 823f29db..d94f6897 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -1415,6 +1415,12 @@ export const kr: Record = {
   'tool.partialInput': '부분 입력',
   'tool.writerPreview': 'Writer',
   'tool.writerPreviewLatest': '{total}줄 중 최신 {visible}줄 표시 중',
+  'tool.contentStats': '{lines} · {chars}',
+  'tool.contentStatsLatest': '최신 {visible} / {total}줄 · {chars}',
+  'tool.lineCountSingular': '{count}줄',
+  'tool.lineCountPlural': '{count}줄',
+  'tool.charCountSingular': '{count}자',
+  'tool.charCountPlural': '{count}자',
   'tool.generatingContent': '콘텐츠 생성 중',
   'tool.preparingEdit': '편집 준비 중',
   'tool.preparingTool': '도구 준비 중',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index 9f3a2fbf..615e4a7c 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -1415,6 +1415,12 @@ export const zh: Record = {
   'tool.partialInput': '部分輸入',
   'tool.writerPreview': 'Writer',
   'tool.writerPreviewLatest': '正在顯示最新 {visible} / {total} 行',
+  'tool.contentStats': '{lines} · {chars}',
+  'tool.contentStatsLatest': '最新 {visible} / {total} 行 · {chars}',
+  'tool.lineCountSingular': '{count} 行',
+  'tool.lineCountPlural': '{count} 行',
+  'tool.charCountSingular': '{count} 字元',
+  'tool.charCountPlural': '{count} 字元',
   'tool.generatingContent': '正在生成內容',
   'tool.preparingEdit': '正在準備編輯',
   'tool.preparingTool': '正在準備工具',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index c0098e3c..de8adddd 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -1415,6 +1415,12 @@ export const zh: Record = {
   'tool.partialInput': '部分输入',
   'tool.writerPreview': 'Writer',
   'tool.writerPreviewLatest': '正在显示最新 {visible} / {total} 行',
+  'tool.contentStats': '{lines} · {chars}',
+  'tool.contentStatsLatest': '最新 {visible} / {total} 行 · {chars}',
+  'tool.lineCountSingular': '{count} 行',
+  'tool.lineCountPlural': '{count} 行',
+  'tool.charCountSingular': '{count} 字符',
+  'tool.charCountPlural': '{count} 字符',
   'tool.generatingContent': '正在生成内容',
   'tool.preparingEdit': '正在准备编辑',
   'tool.preparingTool': '正在准备工具',