diff --git a/apps/mobile/src/features/chat/components/AgentMessage.tsx b/apps/mobile/src/features/chat/components/AgentMessage.tsx index e6fa274cb6..e767f2f43a 100644 --- a/apps/mobile/src/features/chat/components/AgentMessage.tsx +++ b/apps/mobile/src/features/chat/components/AgentMessage.tsx @@ -1,10 +1,10 @@ +import { pickThinkingActivity } from "@posthog/core/sessions/thinkingActivities"; import { Brain } from "phosphor-react-native"; import { useState } from "react"; import { Pressable, Text, View } from "react-native"; import { formatRelativeTime } from "@/lib/format"; import { useThemeColors } from "@/lib/theme"; import { usePeriodicRerender } from "../hooks/usePeriodicRerender"; -import { getRandomThinkingMessage } from "../utils/thinkingMessages"; import { CopyButton } from "./CopyButton"; import { MarkdownText } from "./MarkdownText"; import { ToolMessage } from "./ToolMessage"; @@ -110,7 +110,7 @@ export function AgentMessage({ {isLoading && !content && !thinkingText && ( - {getRandomThinkingMessage()} + {pickThinkingActivity(Math.random())}... )} diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index 3859504777..edf249162b 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -1,3 +1,8 @@ +import { + formatPosthogExecBody, + getPostHogExecDisplay, + isPostHogExecTool, +} from "@posthog/core/sessions/posthogExecDisplay"; import { useRouter } from "expo-router"; import { ArrowsClockwise, @@ -22,11 +27,6 @@ import { TouchableOpacity, View, } from "react-native"; -import { - formatPosthogExecBody, - getPostHogExecDisplay, - isPostHogExecTool, -} from "@/features/chat/utils/posthogExecDisplay"; import { McpAppHost } from "@/features/mcp/components/McpAppHost"; import { isMcpToolName } from "@/features/mcp/utils/mcpToolName"; import { diff --git a/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts b/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts index c2a81f3e0f..ca08b80c33 100644 --- a/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts +++ b/apps/mobile/src/features/chat/utils/posthogExecDisplay.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from "vitest"; import { formatPosthogExecBody, getPostHogExecDisplay, isPostHogExecTool, -} from "./posthogExecDisplay"; +} from "@posthog/core/sessions/posthogExecDisplay"; +import { describe, expect, it } from "vitest"; describe("isPostHogExecTool", () => { it("matches the bare posthog exec tool", () => { diff --git a/apps/mobile/src/features/chat/utils/posthogExecDisplay.ts b/apps/mobile/src/features/chat/utils/posthogExecDisplay.ts deleted file mode 100644 index d46e759f25..0000000000 --- a/apps/mobile/src/features/chat/utils/posthogExecDisplay.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Mirrors the desktop PostHog MCP exec display logic so mobile unwraps the - * dispatched sub-tool instead of showing the raw `exec` transport wrapper. - * - * Supported verbs: - * tools - * search - * info - * schema [field_path] - * call [--json] - */ - -const POSTHOG_EXEC_TOOL_RE = /^mcp__(?:plugin_)?posthog(?:_[^_]+)*__exec$/; - -const POSTHOG_VERB_RE = - /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; -const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; -const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; - -export interface PostHogExecDisplay { - label: string; - input?: string; -} - -export function isPostHogExecTool(toolName: string): boolean { - return POSTHOG_EXEC_TOOL_RE.test(toolName); -} - -export function getPostHogExecDisplay( - toolInput: unknown, -): PostHogExecDisplay | null { - if (!toolInput || typeof toolInput !== "object") return null; - const obj = toolInput as { command?: unknown; input?: unknown }; - - if (typeof obj.command !== "string") return null; - const verbMatch = obj.command.match(POSTHOG_VERB_RE); - if (!verbMatch) return null; - - const verb = verbMatch[1] as "tools" | "search" | "info" | "schema" | "call"; - const rest = (verbMatch[2] ?? "").trim(); - const explicitInput = readExplicitInput(obj.input); - - switch (verb) { - case "tools": - return { label: "List tools", input: undefined }; - - case "search": - return { - label: "Search tools", - input: explicitInput ?? (rest.length > 0 ? rest : undefined), - }; - - case "info": - return rest.length > 0 - ? { label: `Read ${rest}`, input: undefined } - : { label: "Read tool", input: undefined }; - - case "schema": { - const match = rest.match(POSTHOG_TOOL_NAME_RE); - if (!match) return { label: "Inspect schema", input: undefined }; - const subTool = match[1]; - const fieldPath = (match[2] ?? "").trim(); - const path = - explicitInput ?? (fieldPath.length > 0 ? fieldPath : undefined); - return { - label: path - ? `Inspect ${subTool}.${path}` - : `Inspect ${subTool} fields`, - input: undefined, - }; - } - - case "call": { - const match = rest.match(POSTHOG_CALL_BODY_RE); - if (!match) return null; - const subTool = match[1]; - const args = (match[2] ?? "").trim(); - return { - label: subTool, - input: explicitInput ?? (args.length > 0 ? args : undefined), - }; - } - } -} - -function readExplicitInput(value: unknown): string | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value === "string") return value.trim() || undefined; - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} - -export function formatPosthogExecBody( - input: string | undefined, -): string | undefined { - if (!input) return undefined; - try { - const parsed = JSON.parse(input); - if (parsed && typeof parsed === "object") { - return JSON.stringify(parsed, null, 2); - } - } catch { - // Not JSON; show the raw input. - } - return input; -} diff --git a/apps/mobile/src/features/chat/utils/thinkingMessages.test.ts b/apps/mobile/src/features/chat/utils/thinkingMessages.test.ts deleted file mode 100644 index de528d1560..0000000000 --- a/apps/mobile/src/features/chat/utils/thinkingMessages.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - getRandomThinkingActivity, - getRandomThinkingMessage, - THINKING_MESSAGES, -} from "./thinkingMessages"; - -describe("thinkingMessages", () => { - it("includes the whimsical cloud-run loading messages from desktop", () => { - expect(THINKING_MESSAGES).toContain("Kerfuffling"); - expect(THINKING_MESSAGES).toContain("Flibbertigibbeting"); - expect(THINKING_MESSAGES).toContain("Discombobulating"); - }); - - it("returns a bare activity label and a message variant with ellipsis", () => { - const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); - - expect(getRandomThinkingActivity()).toBe("Booping"); - expect(getRandomThinkingMessage()).toBe("Booping..."); - - randomSpy.mockRestore(); - }); -}); diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx index 5bab080e15..e55c5f0f72 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx @@ -24,8 +24,8 @@ vi.mock("@/features/chat", () => ({ deriveToolKind: () => "other", })); -vi.mock("@/features/chat/utils/thinkingMessages", () => ({ - getRandomThinkingActivity: () => "Thinking", +vi.mock("@posthog/core/sessions/thinkingActivities", () => ({ + pickThinkingActivity: () => "Thinking", })); vi.mock("@/lib/theme", () => ({ diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index f30b4aec66..75a06938b0 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -1,3 +1,4 @@ +import { pickThinkingActivity } from "@posthog/core/sessions/thinkingActivities"; import { ArrowDown, Brain, @@ -20,7 +21,6 @@ import { ToolMessage, type ToolStatus, } from "@/features/chat"; -import { getRandomThinkingActivity } from "@/features/chat/utils/thinkingMessages"; import { useThemeColors } from "@/lib/theme"; import type { CloudPendingPermissionRequest, @@ -734,7 +734,9 @@ function useElapsedTimer() { function ThinkingIndicator() { const [dots, setDots] = useState(1); - const [activity, setActivity] = useState(getRandomThinkingActivity); + const [activity, setActivity] = useState(() => + pickThinkingActivity(Math.random()), + ); const elapsed = useElapsedTimer(); const themeColors = useThemeColors(); @@ -747,7 +749,7 @@ function ThinkingIndicator() { useEffect(() => { const interval = setInterval(() => { - setActivity(getRandomThinkingActivity()); + setActivity(pickThinkingActivity(Math.random())); }, 2000); return () => clearInterval(interval); }, []); diff --git a/packages/core/src/sessions/posthogExecDisplay.ts b/packages/core/src/sessions/posthogExecDisplay.ts new file mode 100644 index 0000000000..5b3029a535 --- /dev/null +++ b/packages/core/src/sessions/posthogExecDisplay.ts @@ -0,0 +1,85 @@ +import { parseMcpToolName } from "@posthog/shared"; + +const POSTHOG_SERVER_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/; +const POSTHOG_VERB_RE = + /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; +const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; +const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; + +export interface PostHogExecDisplay { + label: string; + input?: string; +} + +export function isPostHogExecTool(toolName: string): boolean { + const mcp = parseMcpToolName(toolName); + return !!mcp && mcp.tool === "exec" && POSTHOG_SERVER_RE.test(mcp.server); +} + +export function getPostHogExecDisplay( + toolInput: unknown, +): PostHogExecDisplay | null { + if (!toolInput || typeof toolInput !== "object") return null; + const input = toolInput as { command?: unknown; input?: unknown }; + if (typeof input.command !== "string") return null; + const match = input.command.match(POSTHOG_VERB_RE); + if (!match) return null; + const verb = match[1] as "tools" | "search" | "info" | "schema" | "call"; + const rest = (match[2] ?? "").trim(); + const explicitInput = readExplicitInput(input.input); + + switch (verb) { + case "tools": + return { label: "List tools", input: undefined }; + case "search": + return { + label: "Search tools", + input: explicitInput ?? (rest || undefined), + }; + case "info": + return { label: rest ? `Read ${rest}` : "Read tool", input: undefined }; + case "schema": { + const schema = rest.match(POSTHOG_TOOL_NAME_RE); + if (!schema) return { label: "Inspect schema", input: undefined }; + const path = explicitInput ?? ((schema[2] ?? "").trim() || undefined); + return { + label: path + ? `Inspect ${schema[1]}.${path}` + : `Inspect ${schema[1]} fields`, + input: undefined, + }; + } + case "call": { + const call = rest.match(POSTHOG_CALL_BODY_RE); + if (!call) return null; + return { + label: call[1], + input: explicitInput ?? ((call[2] ?? "").trim() || undefined), + }; + } + } +} + +function readExplicitInput(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === "string") return value.trim() || undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +export function formatPosthogExecBody( + input: string | undefined, +): string | undefined { + if (!input) return undefined; + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object") + return JSON.stringify(parsed, null, 2); + } catch { + return input; + } + return input; +} diff --git a/packages/core/src/sessions/thinkingActivities.test.ts b/packages/core/src/sessions/thinkingActivities.test.ts new file mode 100644 index 0000000000..1b1ca94aa6 --- /dev/null +++ b/packages/core/src/sessions/thinkingActivities.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { + pickNextThinkingActivity, + pickThinkingActivity, + THINKING_ACTIVITIES, +} from "./thinkingActivities"; + +describe("thinking activities", () => { + it("selects from a bounded random value", () => { + expect(pickThinkingActivity(0)).toBe("Booping"); + expect(pickThinkingActivity(1)).toBe(THINKING_ACTIVITIES.at(-1)); + }); + + it("always advances when the random pick matches the current activity", () => { + expect(pickNextThinkingActivity("Booping", 0)).toBe("Crunching"); + }); +}); diff --git a/apps/mobile/src/features/chat/utils/thinkingMessages.ts b/packages/core/src/sessions/thinkingActivities.ts similarity index 64% rename from apps/mobile/src/features/chat/utils/thinkingMessages.ts rename to packages/core/src/sessions/thinkingActivities.ts index 35bd41b351..0046775ffc 100644 --- a/apps/mobile/src/features/chat/utils/thinkingMessages.ts +++ b/packages/core/src/sessions/thinkingActivities.ts @@ -1,7 +1,4 @@ -// Random thinking messages displayed while AI is generating -// Based on posthog/frontend/src/scenes/max/utils/thinkingMessages.ts - -export const THINKING_MESSAGES = [ +export const THINKING_ACTIVITIES = [ "Booping", "Crunching", "Digging", @@ -86,13 +83,21 @@ export const THINKING_MESSAGES = [ "Puttering", "Whiffling", "Thinking", -]; +] as const; -export function getRandomThinkingActivity(): string { - const randomIndex = Math.floor(Math.random() * THINKING_MESSAGES.length); - return THINKING_MESSAGES[randomIndex]; +export function pickThinkingActivity(randomValue: number): string { + const bounded = Math.max(0, Math.min(randomValue, 0.999999999999)); + return THINKING_ACTIVITIES[Math.floor(bounded * THINKING_ACTIVITIES.length)]; } -export function getRandomThinkingMessage(): string { - return `${getRandomThinkingActivity()}...`; +export function pickNextThinkingActivity( + current: string, + randomValue: number, +): string { + const picked = pickThinkingActivity(randomValue); + if (picked !== current || THINKING_ACTIVITIES.length <= 1) return picked; + const index = THINKING_ACTIVITIES.indexOf( + current as (typeof THINKING_ACTIVITIES)[number], + ); + return THINKING_ACTIVITIES[(index + 1) % THINKING_ACTIVITIES.length]; } diff --git a/packages/ui/src/features/mcp-apps/components/McpToolView.tsx b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx index 2a40dc99bc..6e99d80395 100644 --- a/packages/ui/src/features/mcp-apps/components/McpToolView.tsx +++ b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx @@ -2,7 +2,7 @@ import { Plugs } from "@phosphor-icons/react"; import { getPostHogExecDisplay, isPostHogExecTool, -} from "../../posthog-mcp/utils/posthog-exec-display"; +} from "@posthog/core/sessions/posthogExecDisplay"; import { useChatThreadChrome } from "../../sessions/components/chat-thread/chatThreadChrome"; import { ToolRow } from "../../sessions/components/session-update/ToolRow"; import { diff --git a/packages/ui/src/features/permissions/McpPermission.tsx b/packages/ui/src/features/permissions/McpPermission.tsx index 6cf59f90cf..cd5ee5961d 100644 --- a/packages/ui/src/features/permissions/McpPermission.tsx +++ b/packages/ui/src/features/permissions/McpPermission.tsx @@ -1,9 +1,9 @@ -import { mcpToolKey, readMcpToolDescriptor } from "@posthog/shared"; import { formatPosthogExecBody, getPostHogExecDisplay, isPostHogExecTool, -} from "@posthog/ui/features/posthog-mcp/utils/posthog-exec-display"; +} from "@posthog/core/sessions/posthogExecDisplay"; +import { mcpToolKey, readMcpToolDescriptor } from "@posthog/shared"; import { formatInput } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { Box, Code } from "@radix-ui/themes"; diff --git a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts index 08aa1b6f74..5a8603b301 100644 --- a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts +++ b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from "vitest"; import { formatPosthogExecBody, getPostHogExecDisplay, isPostHogExecTool, -} from "./posthog-exec-display"; +} from "@posthog/core/sessions/posthogExecDisplay"; +import { describe, expect, it } from "vitest"; describe("isPostHogExecTool", () => { it("matches the bare posthog exec tool", () => { diff --git a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts deleted file mode 100644 index 50cda82a30..0000000000 --- a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * The PostHog MCP exposes a single `exec` dispatcher that runs CLI-style - * subcommands. Generic MCP rendering would show this as - * `posthog - exec (MCP) {"command":"call execute-sql {…}"}` — pure plumbing - * with the dispatched action buried inside a JSON wrapper. - * - * These helpers pull the action out of the `command` string so the row can - * read `posthog - execute-sql {…}` (call), `posthog - Read execute-sql` - * (info), `posthog - Inspect query-trends.series` (schema), - * `posthog - Search tools query-` (search), or `posthog - List tools` - * (tools) instead. - * - * Supported verbs (per the `exec` tool description): - * tools — list every tool - * search — search by name/title/description - * info — show description + input schema - * schema [field_path] — drill into a specific field - * call [--json] — invoke a tool - */ - -import { parseMcpToolName } from "@posthog/shared"; - -// A PostHog MCP server name: optional `plugin_` prefix, `posthog`, then any -// number of `_` parts (e.g. `posthog`, `posthog_cloud`, -// `plugin_posthog_posthog`). The `exec` dispatcher lives on these servers. -const POSTHOG_SERVER_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/; - -const POSTHOG_VERB_RE = - /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; -const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; -const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; - -export interface PostHogExecDisplay { - /** Replaces the tool name in the title — e.g. "execute-sql", "Read execute-sql". */ - label: string; - /** Args to show as the input preview, undefined when there is none to display. */ - input?: string; -} - -export function isPostHogExecTool(toolName: string): boolean { - const mcp = parseMcpToolName(toolName); - return !!mcp && mcp.tool === "exec" && POSTHOG_SERVER_RE.test(mcp.server); -} - -export function getPostHogExecDisplay( - toolInput: unknown, -): PostHogExecDisplay | null { - if (!toolInput || typeof toolInput !== "object") return null; - const obj = toolInput as { command?: unknown; input?: unknown }; - - if (typeof obj.command !== "string") return null; - const verbMatch = obj.command.match(POSTHOG_VERB_RE); - if (!verbMatch) return null; - - const verb = verbMatch[1] as "tools" | "search" | "info" | "schema" | "call"; - const rest = (verbMatch[2] ?? "").trim(); - const explicitInput = readExplicitInput(obj.input); - - switch (verb) { - case "tools": - // `tools` returns names only, not full schemas — "List", not "Read". - return { label: "List tools", input: undefined }; - - case "search": - return { - label: "Search tools", - input: explicitInput ?? (rest.length > 0 ? rest : undefined), - }; - - case "info": - // `info ` — fold the tool name into the label so the args slot stays clean. - return rest.length > 0 - ? { label: `Read ${rest}`, input: undefined } - : { label: "Read tool", input: undefined }; - - case "schema": { - // `schema [field_path]` is the drill-down verb. Fold the - // tool + path into a dotted locator so it reads as one path. - const m = rest.match(POSTHOG_TOOL_NAME_RE); - if (!m) return { label: "Inspect schema", input: undefined }; - const subTool = m[1]; - const fieldPath = (m[2] ?? "").trim(); - const path = - explicitInput ?? (fieldPath.length > 0 ? fieldPath : undefined); - return { - label: path - ? `Inspect ${subTool}.${path}` - : `Inspect ${subTool} fields`, - input: undefined, - }; - } - - case "call": { - // `call [--json] [json_input]` — collapse the verb, surface the - // sub-tool as the label and the JSON body as args. - const m = rest.match(POSTHOG_CALL_BODY_RE); - if (!m) return null; - const subTool = m[1]; - const args = (m[2] ?? "").trim(); - return { - label: subTool, - input: explicitInput ?? (args.length > 0 ? args : undefined), - }; - } - } -} - -function readExplicitInput(value: unknown): string | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value === "string") return value.trim() || undefined; - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} - -/** - * Pretty-prints the unwrapped exec args for display in the permission dialog - * body — JSON payloads (the `call` case) render multi-line; non-JSON args - * (e.g. a `search` regex) pass through unchanged. - */ -export function formatPosthogExecBody( - input: string | undefined, -): string | undefined { - if (!input) return undefined; - try { - const parsed = JSON.parse(input); - if (parsed && typeof parsed === "object") { - return JSON.stringify(parsed, null, 2); - } - } catch { - // not JSON — fall through and show raw - } - return input; -} diff --git a/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx b/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx index 58ea8697a9..47343950d1 100644 --- a/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx +++ b/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx @@ -1,110 +1,19 @@ import { Brain, Circle } from "@phosphor-icons/react"; +import { + pickNextThinkingActivity, + pickThinkingActivity, +} from "@posthog/core/sessions/thinkingActivities"; import { Flex, Text } from "@radix-ui/themes"; import { useEffect, useRef, useState } from "react"; -const THINKING_MESSAGES = [ - "Booping", - "Crunching", - "Digging", - "Fetching", - "Inferring", - "Indexing", - "Juggling", - "Noodling", - "Peeking", - "Percolating", - "Poking", - "Pondering", - "Scanning", - "Scrambling", - "Sifting", - "Sniffing", - "Spelunking", - "Tinkering", - "Unraveling", - "Decoding", - "Trekking", - "Sorting", - "Trimming", - "Mulling", - "Surfacing", - "Rummaging", - "Scouting", - "Scouring", - "Threading", - "Hunting", - "Swizzling", - "Grokking", - "Hedging", - "Scheming", - "Unfurling", - "Puzzling", - "Dissecting", - "Stacking", - "Snuffling", - "Hashing", - "Clustering", - "Teasing", - "Cranking", - "Merging", - "Snooping", - "Rewiring", - "Bundling", - "Linking", - "Mapping", - "Tickling", - "Flicking", - "Hopping", - "Rolling", - "Zipping", - "Twisting", - "Blooming", - "Sparking", - "Nesting", - "Looping", - "Wiring", - "Snipping", - "Zoning", - "Tracing", - "Warping", - "Twinkling", - "Flipping", - "Priming", - "Snagging", - "Scuttling", - "Framing", - "Sharpening", - "Flibbertigibbeting", - "Kerfuffling", - "Dithering", - "Discombobulating", - "Rambling", - "Befuddling", - "Waffling", - "Muckling", - "Hobnobbing", - "Galumphing", - "Puttering", - "Whiffling", - "Thinking", -]; - function getRandomThinkingMessage(): string { - return THINKING_MESSAGES[ - Math.floor(Math.random() * THINKING_MESSAGES.length) - ]; + return pickThinkingActivity(Math.random()); } /** Pick a new word that differs from the current one, so consecutive changes * always read as a change. */ function getNextThinkingMessage(current: string): string { - if (THINKING_MESSAGES.length <= 1) return THINKING_MESSAGES[0]; - let next = current; - while (next === current) { - next = - THINKING_MESSAGES[Math.floor(Math.random() * THINKING_MESSAGES.length)]; - } - return next; + return pickNextThinkingActivity(current, Math.random()); } export function formatDuration(ms: number, fractionDigits = 2): string {