diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue
index 4aaf8aa7fe..fae495199e 100644
--- a/apps/desktop/src/components/editor/AiAssistant.vue
+++ b/apps/desktop/src/components/editor/AiAssistant.vue
@@ -78,6 +78,7 @@ import { AI_TABLE_MENTION_CANDIDATE_LIMIT, AI_TABLE_MENTION_SCHEMA_LIMIT, filter
import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/ai/aiPromptKeyboard";
import { looksLikeActionProposal, containsChinese } from "@/lib/ai/aiProposalDetect";
import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
+import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation";
const { t } = useI18n();
const settings = useSettingsStore();
@@ -1871,6 +1872,7 @@ async function openExternalUrl(url: string) {
{{ t("ai.reasoningProcess") }}
+ {{ msg.reasoning?.length ?? 0 }} {{ t("ai.chars") }}
{
+ it("shows the counter when reasoning exists and the panel is collapsed", () => {
+ expect(shouldShowReasoningCharCount("正在分析表结构...", false)).toBe(true);
+ });
+
+ it("hides the counter when the panel is expanded", () => {
+ expect(shouldShowReasoningCharCount("正在分析表结构...", true)).toBe(false);
+ });
+
+ it("hides the counter when reasoning is empty", () => {
+ expect(shouldShowReasoningCharCount("", false)).toBe(false);
+ });
+
+ it("hides the counter when reasoning is undefined", () => {
+ expect(shouldShowReasoningCharCount(undefined, false)).toBe(false);
+ });
+});
+
+describe("reasoningCharCountClass", () => {
+ it("includes the pulse class while the model is still thinking", () => {
+ const cls = reasoningCharCountClass(true);
+ expect(cls).toContain(REASONING_CHAR_COUNT_BASE_CLASS);
+ expect(cls).toContain(REASONING_CHAR_COUNT_PULSE_CLASS);
+ });
+
+ it("uses only the base class when thinking has finished", () => {
+ expect(reasoningCharCountClass(false)).toBe(REASONING_CHAR_COUNT_BASE_CLASS);
+ });
+});
diff --git a/apps/desktop/src/lib/ai/aiReasoningPresentation.ts b/apps/desktop/src/lib/ai/aiReasoningPresentation.ts
new file mode 100644
index 0000000000..1bb305a97a
--- /dev/null
+++ b/apps/desktop/src/lib/ai/aiReasoningPresentation.ts
@@ -0,0 +1,36 @@
+/**
+ * Pure presentation logic for the collapsed reasoning-process char counter.
+ *
+ * Kept side-effect-free so it can be unit-tested without a DOM harness, matching
+ * the existing pattern of `aiAgentStepPresentation.ts`. The AiAssistant template
+ * consumes these helpers so its render decisions (show/hide, pulse class, text)
+ * are covered by Vitest rather than only by the rendered template.
+ */
+
+/** Base CSS classes for the collapsed reasoning char counter span. */
+export const REASONING_CHAR_COUNT_BASE_CLASS = "tabular-nums text-muted-foreground/60";
+
+/** Extra class added while the model is still producing reasoning deltas. */
+export const REASONING_CHAR_COUNT_PULSE_CLASS = "animate-pulse";
+
+/**
+ * Decide whether the collapsed reasoning char counter should render.
+ * Visible only when there is reasoning text AND the panel is collapsed,
+ * so the counter acts as a "still alive" heartbeat without forcing the
+ * user to open the panel.
+ *
+ * @param reasoning Accumulated reasoning text (may be empty during initial thinking).
+ * @param expanded Whether the reasoning panel is currently expanded.
+ */
+export function shouldShowReasoningCharCount(reasoning: string | undefined, expanded: boolean): boolean {
+ return !expanded && !!reasoning && reasoning.length > 0;
+}
+
+/**
+ * Return the CSS class string for the counter span.
+ * Adds a pulse animation while the model is still thinking so the number
+ * reads as actively growing rather than frozen.
+ */
+export function reasoningCharCountClass(isThinking: boolean): string {
+ return isThinking ? `${REASONING_CHAR_COUNT_BASE_CLASS} ${REASONING_CHAR_COUNT_PULSE_CLASS}` : REASONING_CHAR_COUNT_BASE_CLASS;
+}