@ridit/lens 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/index.mjs +105368 -274002
  2. package/package.json +13 -19
  3. package/src/colors.ts +15 -15
  4. package/src/commands/chat.tsx +32 -23
  5. package/src/commands/provider.tsx +11 -238
  6. package/src/commands/repo.tsx +66 -120
  7. package/src/commands/timeline.tsx +11 -22
  8. package/src/components/ChatView.tsx +238 -0
  9. package/src/components/Message.tsx +46 -0
  10. package/src/components/ToolCall.tsx +67 -0
  11. package/src/components/chat/ChatView.tsx +550 -0
  12. package/src/components/chat/Message.tsx +152 -0
  13. package/src/components/chat/StatusBar.tsx +214 -0
  14. package/src/components/chat/TextArea.tsx +173 -176
  15. package/src/components/provider/ApiKeyStep.tsx +207 -199
  16. package/src/components/provider/ModelStep.tsx +90 -88
  17. package/src/components/provider/ProviderSetup.tsx +331 -0
  18. package/src/components/provider/ProviderTypeStep.tsx +53 -61
  19. package/src/components/repo/StepRow.tsx +68 -69
  20. package/src/components/timeline/TimelineView.tsx +840 -0
  21. package/src/components/toolcall-utils.ts +103 -0
  22. package/src/components/watch/RunView.tsx +497 -0
  23. package/src/hooks/useChatInput.ts +49 -0
  24. package/src/hooks/useCommandHandler.ts +117 -0
  25. package/src/index.tsx +386 -139
  26. package/src/utils/git.ts +149 -155
  27. package/src/utils/repo.ts +62 -69
  28. package/src/utils/thinking.tsx +64 -0
  29. package/src/utils/watch.ts +165 -307
  30. package/tests/message.test.ts +38 -0
  31. package/tests/toolcall-utils.test.ts +111 -0
  32. package/tsconfig.json +8 -24
  33. package/CLAUDE.md +0 -50
  34. package/LENS.md +0 -48
  35. package/LICENSE +0 -21
  36. package/README.md +0 -93
  37. package/addons/README.md +0 -55
  38. package/addons/clean-cache.js +0 -48
  39. package/addons/generate-readme.js +0 -67
  40. package/addons/git-stats.js +0 -29
  41. package/addons/run-tests.js +0 -127
  42. package/src/commands/commit.tsx +0 -668
  43. package/src/commands/review.tsx +0 -294
  44. package/src/commands/run.tsx +0 -56
  45. package/src/commands/task.tsx +0 -36
  46. package/src/components/chat/ChatMessage.tsx +0 -195
  47. package/src/components/chat/ChatOverlays.tsx +0 -399
  48. package/src/components/chat/ChatRunner.tsx +0 -517
  49. package/src/components/chat/hooks/useChat.ts +0 -631
  50. package/src/components/chat/hooks/useChatInput.ts +0 -79
  51. package/src/components/chat/hooks/useCommandHandlers.ts +0 -327
  52. package/src/components/provider/ProviderPicker.tsx +0 -76
  53. package/src/components/provider/RemoveProviderStep.tsx +0 -82
  54. package/src/components/repo/DiffViewer.tsx +0 -175
  55. package/src/components/repo/FileReviewer.tsx +0 -70
  56. package/src/components/repo/FileViewer.tsx +0 -60
  57. package/src/components/repo/IssueFixer.tsx +0 -666
  58. package/src/components/repo/LensFileMenu.tsx +0 -115
  59. package/src/components/repo/NoProviderPrompt.tsx +0 -28
  60. package/src/components/repo/PreviewRunner.tsx +0 -217
  61. package/src/components/repo/RepoAnalysis.tsx +0 -534
  62. package/src/components/task/TaskRunner.tsx +0 -396
  63. package/src/components/timeline/CommitDetail.tsx +0 -272
  64. package/src/components/timeline/CommitList.tsx +0 -162
  65. package/src/components/timeline/TimelineChat.tsx +0 -166
  66. package/src/components/timeline/TimelineRunner.tsx +0 -1285
  67. package/src/components/watch/RunRunner.tsx +0 -929
  68. package/src/prompts/fewshot.ts +0 -252
  69. package/src/prompts/index.ts +0 -2
  70. package/src/prompts/system.ts +0 -285
  71. package/src/tools/chart.ts +0 -202
  72. package/src/tools/convert-image.ts +0 -312
  73. package/src/tools/files.ts +0 -253
  74. package/src/tools/git.ts +0 -603
  75. package/src/tools/index.ts +0 -17
  76. package/src/tools/pdf.ts +0 -164
  77. package/src/tools/shell.ts +0 -96
  78. package/src/tools/view-image.ts +0 -335
  79. package/src/tools/web.ts +0 -212
  80. package/src/types/chat.ts +0 -86
  81. package/src/types/config.ts +0 -20
  82. package/src/types/repo.ts +0 -54
  83. package/src/utils/addons/loadAddons.ts +0 -34
  84. package/src/utils/ai.ts +0 -321
  85. package/src/utils/chat.ts +0 -326
  86. package/src/utils/chatHistory.ts +0 -121
  87. package/src/utils/config.ts +0 -61
  88. package/src/utils/files.ts +0 -105
  89. package/src/utils/intentClassifier.ts +0 -58
  90. package/src/utils/lensfile.ts +0 -142
  91. package/src/utils/llm.ts +0 -81
  92. package/src/utils/memory.ts +0 -209
  93. package/src/utils/preview.ts +0 -119
  94. package/src/utils/stats.ts +0 -174
  95. package/src/utils/tools/builtins.ts +0 -377
  96. package/src/utils/tools/registry.ts +0 -105
package/src/utils/git.ts CHANGED
@@ -1,155 +1,149 @@
1
- import { execSync } from "child_process";
2
-
3
- export type Commit = {
4
- hash: string;
5
- shortHash: string;
6
- author: string;
7
- email: string;
8
- date: string;
9
- relativeDate: string;
10
- message: string;
11
- body: string;
12
- refs: string;
13
- parents: string[];
14
- filesChanged: number;
15
- insertions: number;
16
- deletions: number;
17
- };
18
-
19
- export type DiffFile = {
20
- path: string;
21
- status: "added" | "modified" | "deleted" | "renamed";
22
- insertions: number;
23
- deletions: number;
24
- lines: { type: "add" | "remove" | "context" | "header"; content: string }[];
25
- };
26
-
27
- function run(cmd: string, cwd: string): string {
28
- try {
29
- return execSync(cmd, {
30
- cwd,
31
- encoding: "utf-8",
32
- stdio: ["pipe", "pipe", "pipe"],
33
- }).trim();
34
- } catch {
35
- return "";
36
- }
37
- }
38
-
39
- export function isGitRepo(p: string): boolean {
40
- return run("git rev-parse --is-inside-work-tree", p) === "true";
41
- }
42
-
43
- export function fetchCommits(repoPath: string, limit = 200): Commit[] {
44
- const SEP = "|||";
45
- const RS = "^^^";
46
- const raw = run(
47
- `git log --max-count=${limit} --format="${RS}%H${SEP}%h${SEP}%an${SEP}%ae${SEP}%ci${SEP}%cr${SEP}%s${SEP}%b${SEP}%D${SEP}%P" --shortstat`,
48
- repoPath,
49
- );
50
- if (!raw) return [];
51
-
52
- return raw
53
- .split(RS)
54
- .filter(Boolean)
55
- .flatMap((block) => {
56
- const lines = block.split("\n");
57
- const parts = lines[0]!.split(SEP);
58
- if (parts.length < 10) return [];
59
- const [
60
- hash,
61
- shortHash,
62
- author,
63
- email,
64
- date,
65
- relativeDate,
66
- message,
67
- body,
68
- refs,
69
- parentsRaw,
70
- ] = parts;
71
- const statLine = lines.find((l) => l.includes("changed")) ?? "";
72
- return [
73
- {
74
- hash: hash!.trim(),
75
- shortHash: shortHash!.trim(),
76
- author: author!.trim(),
77
- email: email!.trim(),
78
- date: date!.trim(),
79
- relativeDate: relativeDate!.trim(),
80
- message: message!.trim(),
81
- body: body!.trim(),
82
- refs: refs!.trim(),
83
- parents: parentsRaw!.trim().split(" ").filter(Boolean),
84
- filesChanged: parseInt(statLine.match(/(\d+) file/)?.[1] ?? "0"),
85
- insertions: parseInt(statLine.match(/(\d+) insertion/)?.[1] ?? "0"),
86
- deletions: parseInt(statLine.match(/(\d+) deletion/)?.[1] ?? "0"),
87
- },
88
- ];
89
- });
90
- }
91
-
92
- export function fetchDiff(repoPath: string, hash: string): DiffFile[] {
93
- const raw = run(
94
- `git show --unified=3 --diff-filter=ACDMR "${hash}"`,
95
- repoPath,
96
- );
97
- if (!raw) return [];
98
-
99
- const files: DiffFile[] = [];
100
- let cur: DiffFile | null = null;
101
-
102
- for (const line of raw.split("\n")) {
103
- if (line.startsWith("diff --git")) {
104
- if (cur) files.push(cur);
105
- cur = {
106
- path: "",
107
- status: "modified",
108
- insertions: 0,
109
- deletions: 0,
110
- lines: [],
111
- };
112
- } else if (line.startsWith("+++ b/") && cur) {
113
- cur.path = line.slice(6);
114
- } else if (line.startsWith("new file") && cur) {
115
- cur.status = "added";
116
- } else if (line.startsWith("deleted file") && cur) {
117
- cur.status = "deleted";
118
- } else if (line.startsWith("rename") && cur) {
119
- cur.status = "renamed";
120
- } else if (line.startsWith("@@") && cur) {
121
- cur.lines.push({ type: "header", content: line });
122
- } else if (line.startsWith("+") && cur && !line.startsWith("+++")) {
123
- cur.insertions++;
124
- cur.lines.push({ type: "add", content: line.slice(1) });
125
- } else if (line.startsWith("-") && cur && !line.startsWith("---")) {
126
- cur.deletions++;
127
- cur.lines.push({ type: "remove", content: line.slice(1) });
128
- } else if (cur && line !== "\") {
129
- cur.lines.push({ type: "context", content: line.slice(1) });
130
- }
131
- }
132
- if (cur) files.push(cur);
133
- return files.filter((f) => f.path);
134
- }
135
-
136
- export function summarizeTimeline(commits: Commit[]): string {
137
- if (!commits.length) return "No commits.";
138
- const authors = [...new Set(commits.map((c) => c.author))];
139
- const biggest = [...commits].sort(
140
- (a, b) => b.insertions + b.deletions - (a.insertions + a.deletions),
141
- )[0]!;
142
- return [
143
- `Total commits: ${commits.length}`,
144
- `Authors: ${authors.join(", ")}`,
145
- `Newest: "${commits[0]!.message}" (${commits[0]!.shortHash}) — ${commits[0]!.relativeDate}`,
146
- `Oldest: "${commits[commits.length - 1]!.message}" (${commits[commits.length - 1]!.shortHash}) ${commits[commits.length - 1]!.relativeDate}`,
147
- `Biggest change: "${biggest.message}" (${biggest.shortHash}) +${biggest.insertions}/-${biggest.deletions}`,
148
- ``,
149
- `Full log (hash | date | author | message | +ins/-del):`,
150
- ...commits.map(
151
- (c) =>
152
- `${c.shortHash} | ${c.date.slice(0, 10)} | ${c.author} | ${c.message} | +${c.insertions}/-${c.deletions}`,
153
- ),
154
- ].join("\n");
155
- }
1
+ import { execSync } from "child_process";
2
+
3
+ export type Commit = {
4
+ hash: string;
5
+ shortHash: string;
6
+ author: string;
7
+ email: string;
8
+ date: string;
9
+ relativeDate: string;
10
+ message: string;
11
+ body: string;
12
+ refs: string;
13
+ parents: string[];
14
+ filesChanged: number;
15
+ insertions: number;
16
+ deletions: number;
17
+ };
18
+
19
+ export type DiffFile = {
20
+ path: string;
21
+ status: "added" | "modified" | "deleted" | "renamed";
22
+ insertions: number;
23
+ deletions: number;
24
+ lines: { type: "add" | "remove" | "context" | "header"; content: string }[];
25
+ };
26
+
27
+ function run(cmd: string, cwd: string): string {
28
+ try {
29
+ return execSync(cmd, {
30
+ cwd,
31
+ encoding: "utf-8",
32
+ stdio: ["pipe", "pipe", "pipe"],
33
+ }).trim();
34
+ } catch {
35
+ return "";
36
+ }
37
+ }
38
+
39
+ export function isGitRepo(p: string): boolean {
40
+ return run("git rev-parse --is-inside-work-tree", p) === "true";
41
+ }
42
+
43
+ export function fetchCommits(repoPath: string, limit = 200): Commit[] {
44
+ const SEP = "|||";
45
+ const RS = "^^^";
46
+ const raw = run(
47
+ `git log --max-count=${limit} --format="${RS}%H${SEP}%h${SEP}%an${SEP}%ae${SEP}%ci${SEP}%cr${SEP}%s${SEP}%b${SEP}%D${SEP}%P" --shortstat`,
48
+ repoPath,
49
+ );
50
+ if (!raw) return [];
51
+
52
+ return raw
53
+ .split(RS)
54
+ .filter(Boolean)
55
+ .flatMap((block) => {
56
+ const lines = block.split("\n");
57
+ const parts = lines[0]!.split(SEP);
58
+ if (parts.length < 10) return [];
59
+ const [
60
+ hash,
61
+ shortHash,
62
+ author,
63
+ email,
64
+ date,
65
+ relativeDate,
66
+ message,
67
+ body,
68
+ refs,
69
+ parentsRaw,
70
+ ] = parts;
71
+ const statLine = lines.find((l) => l.includes("changed")) ?? "";
72
+ return [
73
+ {
74
+ hash: hash!.trim(),
75
+ shortHash: shortHash!.trim(),
76
+ author: author!.trim(),
77
+ email: email!.trim(),
78
+ date: date!.trim(),
79
+ relativeDate: relativeDate!.trim(),
80
+ message: message!.trim(),
81
+ body: body!.trim(),
82
+ refs: refs!.trim(),
83
+ parents: parentsRaw!.trim().split(" ").filter(Boolean),
84
+ filesChanged: parseInt(statLine.match(/(\d+) file/)?.[1] ?? "0"),
85
+ insertions: parseInt(statLine.match(/(\d+) insertion/)?.[1] ?? "0"),
86
+ deletions: parseInt(statLine.match(/(\d+) deletion/)?.[1] ?? "0"),
87
+ },
88
+ ];
89
+ });
90
+ }
91
+
92
+ export function fetchDiff(repoPath: string, hash: string): DiffFile[] {
93
+ const raw = run(
94
+ `git show --unified=3 --diff-filter=ACDMR "${hash}"`,
95
+ repoPath,
96
+ );
97
+ if (!raw) return [];
98
+
99
+ const files: DiffFile[] = [];
100
+ let cur: DiffFile | null = null;
101
+
102
+ for (const line of raw.split("\n")) {
103
+ if (line.startsWith("diff --git")) {
104
+ if (cur) files.push(cur);
105
+ cur = { path: "", status: "modified", insertions: 0, deletions: 0, lines: [] };
106
+ } else if (line.startsWith("+++ b/") && cur) {
107
+ cur.path = line.slice(6);
108
+ } else if (line.startsWith("new file") && cur) {
109
+ cur.status = "added";
110
+ } else if (line.startsWith("deleted file") && cur) {
111
+ cur.status = "deleted";
112
+ } else if (line.startsWith("rename") && cur) {
113
+ cur.status = "renamed";
114
+ } else if (line.startsWith("@@") && cur) {
115
+ cur.lines.push({ type: "header", content: line });
116
+ } else if (line.startsWith("+") && cur && !line.startsWith("+++")) {
117
+ cur.insertions++;
118
+ cur.lines.push({ type: "add", content: line.slice(1) });
119
+ } else if (line.startsWith("-") && cur && !line.startsWith("---")) {
120
+ cur.deletions++;
121
+ cur.lines.push({ type: "remove", content: line.slice(1) });
122
+ } else if (cur && line !== "\") {
123
+ cur.lines.push({ type: "context", content: line.slice(1) });
124
+ }
125
+ }
126
+ if (cur) files.push(cur);
127
+ return files.filter((f) => f.path);
128
+ }
129
+
130
+ export function summarizeTimeline(commits: Commit[]): string {
131
+ if (!commits.length) return "No commits.";
132
+ const authors = [...new Set(commits.map((c) => c.author))];
133
+ const biggest = [...commits].sort(
134
+ (a, b) => b.insertions + b.deletions - (a.insertions + a.deletions),
135
+ )[0]!;
136
+ return [
137
+ `Total commits: ${commits.length}`,
138
+ `Authors: ${authors.join(", ")}`,
139
+ `Newest: "${commits[0]!.message}" (${commits[0]!.shortHash}) — ${commits[0]!.relativeDate}`,
140
+ `Oldest: "${commits[commits.length - 1]!.message}" (${commits[commits.length - 1]!.shortHash}) — ${commits[commits.length - 1]!.relativeDate}`,
141
+ `Biggest change: "${biggest.message}" (${biggest.shortHash}) +${biggest.insertions}/-${biggest.deletions}`,
142
+ ``,
143
+ `Full log (hash | date | author | message | +ins/-del):`,
144
+ ...commits.map(
145
+ (c) =>
146
+ `${c.shortHash} | ${c.date.slice(0, 10)} | ${c.author} | ${c.message} | +${c.insertions}/-${c.deletions}`,
147
+ ),
148
+ ].join("\n");
149
+ }
package/src/utils/repo.ts CHANGED
@@ -1,69 +1,62 @@
1
- import fs from "fs";
2
- import os from "os";
3
- import path from "path";
4
- import { exec } from "child_process";
5
-
6
- type CloneResult =
7
- | { done: true }
8
- | { done: false; folderExists: true; repoPath: string }
9
- | { done: false; folderExists?: false; error: string };
10
-
11
- export function cloneRepo(
12
- url: string,
13
- repoPath: string,
14
- ): Promise<{ done: boolean; error?: string }> {
15
- return new Promise((resolve) => {
16
- exec(`git clone "${url}" "${repoPath}"`, (err) => {
17
- if (err) {
18
- resolve({ done: false, error: err.message });
19
- } else {
20
- resolve({ done: true });
21
- }
22
- });
23
- });
24
- }
25
-
26
- export function deleteRepoFolder(repoPath: string): void {
27
- fs.rmSync(repoPath, { recursive: true, force: true });
28
- }
29
-
30
- export async function startCloneRepo(
31
- url: string,
32
- opts: { forceReclone?: boolean } = {},
33
- ): Promise<CloneResult> {
34
- let parsedUrl: URL;
35
- try {
36
- parsedUrl = new URL(url);
37
- } catch {
38
- return { done: false, error: `Invalid URL: ${url}` };
39
- }
40
-
41
- const repoName = path.basename(parsedUrl.pathname).replace(/\.git$/, "");
42
- if (!repoName) {
43
- return {
44
- done: false,
45
- error: "Could not determine repository name from URL.",
46
- };
47
- }
48
-
49
- const repoPath = path.join(os.tmpdir(), repoName);
50
-
51
- const firstTry = await cloneRepo(url, repoPath);
52
- if (!firstTry.error) {
53
- return { done: true };
54
- }
55
-
56
- if (firstTry.error.includes("already exists")) {
57
- if (!opts.forceReclone) {
58
- return { done: false, folderExists: true, repoPath };
59
- }
60
-
61
- deleteRepoFolder(repoPath);
62
- const secondTry = await cloneRepo(url, repoPath);
63
- return secondTry.error
64
- ? { done: false, error: secondTry.error }
65
- : { done: true };
66
- }
67
-
68
- return { done: false, error: firstTry.error };
69
- }
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { exec } from "child_process";
5
+
6
+ export type CloneResult =
7
+ | { done: true; repoPath: string }
8
+ | { done: false; folderExists: true; repoPath: string }
9
+ | { done: false; folderExists?: false; error: string };
10
+
11
+ function cloneRepo(
12
+ url: string,
13
+ repoPath: string,
14
+ ): Promise<{ done: boolean; error?: string }> {
15
+ return new Promise((resolve) => {
16
+ exec(`git clone "${url}" "${repoPath}"`, (err) => {
17
+ if (err) resolve({ done: false, error: err.message });
18
+ else resolve({ done: true });
19
+ });
20
+ });
21
+ }
22
+
23
+ function deleteRepoFolder(repoPath: string): void {
24
+ fs.rmSync(repoPath, { recursive: true, force: true });
25
+ }
26
+
27
+ export async function startCloneRepo(
28
+ url: string,
29
+ opts: { forceReclone?: boolean } = {},
30
+ ): Promise<CloneResult> {
31
+ let parsedUrl: URL;
32
+ try {
33
+ parsedUrl = new URL(url);
34
+ } catch {
35
+ return { done: false, error: `Invalid URL: ${url}` };
36
+ }
37
+
38
+ const repoName = path.basename(parsedUrl.pathname).replace(/\.git$/, "");
39
+ if (!repoName) {
40
+ return { done: false, error: "Could not determine repository name from URL." };
41
+ }
42
+
43
+ const repoPath = path.join(os.tmpdir(), repoName);
44
+
45
+ const firstTry = await cloneRepo(url, repoPath);
46
+ if (!firstTry.error) {
47
+ return { done: true, repoPath };
48
+ }
49
+
50
+ if (firstTry.error.includes("already exists")) {
51
+ if (!opts.forceReclone) {
52
+ return { done: false, folderExists: true, repoPath };
53
+ }
54
+ deleteRepoFolder(repoPath);
55
+ const secondTry = await cloneRepo(url, repoPath);
56
+ return secondTry.error
57
+ ? { done: false, error: secondTry.error }
58
+ : { done: true, repoPath };
59
+ }
60
+
61
+ return { done: false, error: firstTry.error };
62
+ }
@@ -1,5 +1,23 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
 
3
+ const TIPS = [
4
+ "use /auto to toggle auto-approve for safe tools",
5
+ "ctrl+f to toggle force-all mode",
6
+ "shift+enter for a new line in the input",
7
+ "↑ / ↓ arrows navigate message history",
8
+ "ctrl+w deletes the previous word",
9
+ "ctrl+delete deletes the next word",
10
+ "/timeline to browse commit history",
11
+ "/review to analyze the current codebase",
12
+ "/memory list to view stored memories",
13
+ "/chat list to see saved conversations",
14
+ "esc cancels a running response",
15
+ "/clear history resets session memory",
16
+ "tab autocompletes slash commands",
17
+ "lens commit --auto for fast AI commits",
18
+ "lens run \"bun dev\" to watch and auto-fix errors",
19
+ ];
20
+
3
21
  const PHRASES: Record<string, string[]> = {
4
22
  general: [
5
23
  "marinating on that... 🍖",
@@ -321,6 +339,52 @@ const PHRASES: Record<string, string[]> = {
321
339
 
322
340
  export type ThinkingKind = keyof typeof PHRASES;
323
341
 
342
+ export function useThinkingTip(active: boolean, intervalMs = 8000): string {
343
+ const [index, setIndex] = useState(() => Math.floor(Math.random() * TIPS.length));
344
+ const usedRef = useRef<Set<number>>(new Set());
345
+
346
+ useEffect(() => {
347
+ if (!active) return;
348
+ const pickUnused = () => {
349
+ if (usedRef.current.size >= TIPS.length) usedRef.current.clear();
350
+ let next: number;
351
+ do {
352
+ next = Math.floor(Math.random() * TIPS.length);
353
+ } while (usedRef.current.has(next));
354
+ usedRef.current.add(next);
355
+ return next;
356
+ };
357
+ setIndex(pickUnused());
358
+ const id = setInterval(() => setIndex(pickUnused()), intervalMs);
359
+ return () => clearInterval(id);
360
+ }, [active, intervalMs]);
361
+
362
+ return TIPS[index]!;
363
+ }
364
+
365
+ export function useThinkingTimer(active: boolean): string {
366
+ const [seconds, setSeconds] = useState(0);
367
+ const startRef = useRef<number | null>(null);
368
+
369
+ useEffect(() => {
370
+ if (active) {
371
+ startRef.current = Date.now();
372
+ setSeconds(0);
373
+ const id = setInterval(() => {
374
+ setSeconds(Math.floor((Date.now() - (startRef.current ?? Date.now())) / 1000));
375
+ }, 1000);
376
+ return () => clearInterval(id);
377
+ } else {
378
+ startRef.current = null;
379
+ setSeconds(0);
380
+ }
381
+ }, [active]);
382
+
383
+ if (!active || seconds === 0) return "";
384
+ if (seconds < 60) return `${seconds}s`;
385
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
386
+ }
387
+
324
388
  export function useThinkingPhrase(
325
389
  active: boolean,
326
390
  kind: ThinkingKind = "general",