@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
@@ -0,0 +1,550 @@
1
+ import React, { useState, useRef } from "react";
2
+ import { Box, Text, Static, useInput } from "ink";
3
+ import { ACCENT, GREEN, RED } from "../../colors";
4
+ import { AppHeader, InputBox, ShortcutBar, TypewriterText } from "./StatusBar";
5
+ import { StaticMessage } from "./Message";
6
+ import { MessageBody } from "@ridit/ink-ui";
7
+ import type { UIMessage } from "./Message";
8
+ import { ProviderSetup } from "../provider/ProviderSetup";
9
+ import {
10
+ useThinkingPhrase,
11
+ useThinkingTip,
12
+ useThinkingTimer,
13
+ } from "../../utils/thinking";
14
+ import {
15
+ chat,
16
+ createSession,
17
+ createSessionWithId,
18
+ addMessage,
19
+ appendMessages,
20
+ getMessages,
21
+ getSystemPrompt,
22
+ saveSession,
23
+ loadSession,
24
+ getLatestSession,
25
+ getActiveModelName,
26
+ } from "@ridit/lens-core";
27
+ import { useChatInput } from "../../hooks/useChatInput";
28
+ import { handleCommand } from "../../hooks/useCommandHandler";
29
+ import Spinner from "ink-spinner";
30
+
31
+ // ── Static header (renders once, stays pinned) ────────────────────────────────
32
+
33
+ const HEADER_ITEMS = [{ type: "header" as const }];
34
+
35
+ // ── Commands ──────────────────────────────────────────────────────────────────
36
+
37
+ export const COMMANDS = [
38
+ { cmd: "/auto", desc: "toggle auto-approve for read/search tools" },
39
+ {
40
+ cmd: "/auto --force-all",
41
+ desc: "auto-approve ALL tools including shell and writes (⚠ dangerous)",
42
+ },
43
+ { cmd: "/clear history", desc: "wipe session memory for this repo" },
44
+ { cmd: "/memory", desc: "memory commands" },
45
+ { cmd: "/memory list", desc: "list all memories for this repo" },
46
+ { cmd: "/memory add", desc: "add a memory" },
47
+ { cmd: "/memory delete", desc: "delete a memory by id" },
48
+ { cmd: "/memory clear", desc: "clear all memories" },
49
+ { cmd: "/provider", desc: "configure AI provider" },
50
+ ];
51
+
52
+ // ── Tool helpers ──────────────────────────────────────────────────────────────
53
+
54
+ const TOOL_ICONS: Record<string, string> = {
55
+ bash: "$",
56
+ read: "r",
57
+ write: "w",
58
+ grep: "/",
59
+ ls: "d",
60
+ remember: "·",
61
+ search: "?",
62
+ scrape: "↓",
63
+ };
64
+
65
+ const SAFE_TOOLS = new Set(["read", "grep", "ls", "remember", "search", "scrape"]);
66
+
67
+ function getToolLabel(tool: string, args: unknown): string {
68
+ if (!args || typeof args !== "object") return tool;
69
+ const a = args as Record<string, unknown>;
70
+ switch (tool) {
71
+ case "read":
72
+ return String(a.path ?? a.file_path ?? "");
73
+ case "write":
74
+ return String(a.path ?? a.file_path ?? a.filename ?? "");
75
+ case "bash":
76
+ return String(a.command ?? a.cmd ?? "");
77
+ case "grep": {
78
+ const p = String(a.pattern ?? "");
79
+ const g = String(a.glob ?? "");
80
+ return g ? `${p} ${g}` : p;
81
+ }
82
+ case "ls":
83
+ return String(a.path ?? ".");
84
+ case "remember": {
85
+ const c = String(a.content ?? "");
86
+ return c.length > 80 ? c.slice(0, 80) + "…" : c;
87
+ }
88
+ default:
89
+ return "";
90
+ }
91
+ }
92
+
93
+ function summarizeResult(result: string): string {
94
+ const first = result.split("\n")[0] ?? "";
95
+ return first.length > 120 ? first.slice(0, 120) + "…" : first;
96
+ }
97
+
98
+ // ── Command palette ───────────────────────────────────────────────────────────
99
+
100
+ function CommandPalette({ query }: { query: string }) {
101
+ const q = query.toLowerCase();
102
+ const matches = COMMANDS.filter((c) => c.cmd.startsWith(q));
103
+ if (!matches.length) return null;
104
+ return (
105
+ <Box flexDirection="column" marginBottom={1} marginLeft={2}>
106
+ {matches.map((c, i) => (
107
+ <Box key={i} gap={2}>
108
+ <Text
109
+ color={c.cmd === query ? ACCENT : "white"}
110
+ bold={c.cmd === query}
111
+ >
112
+ {c.cmd}
113
+ </Text>
114
+ <Text color="gray" dimColor>
115
+ {c.desc}
116
+ </Text>
117
+ </Box>
118
+ ))}
119
+ </Box>
120
+ );
121
+ }
122
+
123
+ // ── Main runner ───────────────────────────────────────────────────────────────
124
+
125
+ export function ChatRunner({
126
+ repoPath,
127
+ autoForce = false,
128
+ initialMessage,
129
+ dev = false,
130
+ single = false,
131
+ sessionId,
132
+ }: {
133
+ repoPath: string;
134
+ autoForce?: boolean;
135
+ initialMessage?: string;
136
+ dev?: boolean;
137
+ single?: boolean;
138
+ sessionId?: string;
139
+ }) {
140
+ const [stage, setStage] = useState<"idle" | "thinking">("idle");
141
+ const [showProvider, setShowProvider] = useState(false);
142
+ const [committed, setCommitted] = useState<UIMessage[]>([]);
143
+ const [currentChunk, setCurrentChunk] = useState("");
144
+ const [autoApprove, setAutoApprove] = useState(autoForce);
145
+ const [forceApprove, setForceApprove] = useState(autoForce);
146
+ const forceApproveRef = useRef(autoForce);
147
+ const [approvalRequest, setApprovalRequest] = useState<{
148
+ tool: string;
149
+ args: unknown;
150
+ label: string;
151
+ } | null>(null);
152
+ const approvalResolveRef = useRef<((approved: boolean) => void) | null>(null);
153
+
154
+ // session:
155
+ // --session <id> → resume if exists, else create with that exact id
156
+ // --single → resume latest session for repo (or fresh)
157
+ // default → fresh session
158
+ const sessionRef = useRef(
159
+ sessionId
160
+ ? (loadSession(sessionId) ?? createSessionWithId(sessionId, repoPath))
161
+ : single
162
+ ? (getLatestSession(repoPath) ?? createSession(repoPath))
163
+ : createSession(repoPath),
164
+ );
165
+
166
+ const abortRef = useRef<AbortController | null>(null);
167
+ const pendingToolRef = useRef<{ tool: string; args: unknown } | null>(null);
168
+
169
+ const isThinking = stage === "thinking";
170
+ const thinkingPhrase = useThinkingPhrase(isThinking);
171
+ const thinkingTip = useThinkingTip(isThinking);
172
+ const thinkingTimer = useThinkingTimer(isThinking);
173
+
174
+ const {
175
+ inputValue,
176
+ setInputValue,
177
+ inputKey,
178
+ pushHistory,
179
+ historyUp,
180
+ historyDown,
181
+ clear,
182
+ } = useChatInput(initialMessage);
183
+
184
+ const pushMsg = (msg: UIMessage) => setCommitted((prev) => [...prev, msg]);
185
+
186
+ // ── Keyboard ───────────────────────────────────────────────────────────────
187
+
188
+ useInput((input, key) => {
189
+ if (key.ctrl && input === "c") process.exit(0);
190
+
191
+ if (approvalRequest) {
192
+ if (input === "y") {
193
+ approvalResolveRef.current?.(true);
194
+ approvalResolveRef.current = null;
195
+ setApprovalRequest(null);
196
+ } else if (input === "n") {
197
+ approvalResolveRef.current?.(false);
198
+ approvalResolveRef.current = null;
199
+ setApprovalRequest(null);
200
+ } else if (input === "a") {
201
+ forceApproveRef.current = true;
202
+ setForceApprove(true);
203
+ setAutoApprove(true);
204
+ approvalResolveRef.current?.(true);
205
+ approvalResolveRef.current = null;
206
+ setApprovalRequest(null);
207
+ }
208
+ return;
209
+ }
210
+
211
+ if (key.ctrl && input === "f" && stage === "idle") {
212
+ if (forceApprove) {
213
+ forceApproveRef.current = false;
214
+ setForceApprove(false);
215
+ setAutoApprove(false);
216
+ pushMsg({
217
+ role: "assistant",
218
+ type: "text",
219
+ content: "Force-all mode OFF — tools will ask for permission again.",
220
+ });
221
+ } else {
222
+ forceApproveRef.current = true;
223
+ setForceApprove(true);
224
+ setAutoApprove(true);
225
+ pushMsg({
226
+ role: "assistant",
227
+ type: "text",
228
+ content:
229
+ "⚡⚡ Force-all mode ON (dangerous) — ALL tools auto-approved including shell and writes. Type /auto --force-all again to disable.",
230
+ });
231
+ }
232
+ return;
233
+ }
234
+
235
+ if (stage === "thinking" && key.escape) {
236
+ abortRef.current?.abort();
237
+ abortRef.current = null;
238
+ setCurrentChunk("");
239
+ setStage("idle");
240
+ return;
241
+ }
242
+
243
+ if (stage === "idle") {
244
+ if (key.upArrow) {
245
+ historyUp();
246
+ return;
247
+ }
248
+ if (key.downArrow) {
249
+ historyDown();
250
+ return;
251
+ }
252
+ if (key.tab && inputValue.startsWith("/")) {
253
+ const match = COMMANDS.find((c) =>
254
+ c.cmd.startsWith(inputValue.toLowerCase()),
255
+ );
256
+ if (match) setInputValue(match.cmd);
257
+ return;
258
+ }
259
+ }
260
+ });
261
+
262
+ // ── Send message ───────────────────────────────────────────────────────────
263
+
264
+ const sendMessage = async (text: string) => {
265
+ if (!text.trim() || stage !== "idle") return;
266
+
267
+ pushHistory(text);
268
+
269
+ if (text.startsWith("/")) {
270
+ if (
271
+ handleCommand(text, {
272
+ repoPath,
273
+ autoApprove,
274
+ forceApprove,
275
+ setAutoApprove,
276
+ setForceApprove,
277
+ pushMsg,
278
+ resetSession: () => {
279
+ sessionRef.current = createSession(repoPath);
280
+ },
281
+ openProvider: () => setShowProvider(true),
282
+ })
283
+ )
284
+ return;
285
+ }
286
+
287
+ // dev mode — output structured JSON to stdout and exit
288
+ if (dev) {
289
+ sessionRef.current = addMessage(sessionRef.current, "user", text);
290
+
291
+ const devTools: { tool: string; args: unknown; result: unknown }[] = [];
292
+ try {
293
+ await chat({
294
+ messages: getMessages(sessionRef.current),
295
+ system: getSystemPrompt(repoPath),
296
+ onChunk: () => {},
297
+ onToolCall: (tool, args) => {
298
+ devTools.push({ tool, args, result: null });
299
+ },
300
+ onToolResult: (tool, result) => {
301
+ const entry = [...devTools]
302
+ .reverse()
303
+ .find((t) => t.tool === tool && t.result === null);
304
+ if (entry) entry.result = result;
305
+ },
306
+ onFinish: (fullText, responseMessages, model) => {
307
+ if (!single) {
308
+ sessionRef.current = appendMessages(
309
+ sessionRef.current,
310
+ responseMessages,
311
+ );
312
+ saveSession(sessionRef.current);
313
+ }
314
+ process.stdout.write(
315
+ JSON.stringify({
316
+ message: fullText,
317
+ model,
318
+ sessionId: sessionRef.current.id,
319
+ tools: devTools,
320
+ }) + "\n",
321
+ );
322
+ process.exit(0);
323
+ },
324
+ });
325
+ } catch (err) {
326
+ const msg = err instanceof Error ? err.message : String(err);
327
+ process.stdout.write(JSON.stringify({ error: msg }) + "\n");
328
+ process.exit(1);
329
+ }
330
+ return;
331
+ }
332
+
333
+ pushMsg({ role: "user", type: "text", content: text });
334
+ sessionRef.current = addMessage(sessionRef.current, "user", text);
335
+
336
+ setStage("thinking");
337
+ setCurrentChunk("");
338
+
339
+ const abort = new AbortController();
340
+ abortRef.current = abort;
341
+
342
+ abort.signal.addEventListener("abort", () => {
343
+ if (approvalResolveRef.current) {
344
+ approvalResolveRef.current(false);
345
+ approvalResolveRef.current = null;
346
+ setApprovalRequest(null);
347
+ }
348
+ });
349
+
350
+ try {
351
+ await chat({
352
+ messages: getMessages(sessionRef.current),
353
+ system: getSystemPrompt(repoPath),
354
+ onBeforeToolCall: (tool, args) => {
355
+ if (forceApproveRef.current || SAFE_TOOLS.has(tool))
356
+ return Promise.resolve(true);
357
+ const label = getToolLabel(tool, args);
358
+ return new Promise((resolve) => {
359
+ setApprovalRequest({ tool, args, label });
360
+ approvalResolveRef.current = resolve;
361
+ });
362
+ },
363
+ onChunk: (chunk) => {
364
+ if (!abort.signal.aborted) setCurrentChunk((prev) => prev + chunk);
365
+ },
366
+ onToolCall: (tool, args) => {
367
+ if (!abort.signal.aborted) pendingToolRef.current = { tool, args };
368
+ },
369
+ onToolResult: (tool, result) => {
370
+ if (!abort.signal.aborted && pendingToolRef.current) {
371
+ const { tool: t, args } = pendingToolRef.current;
372
+ const label = (getToolLabel(t, args) || TOOL_ICONS[t]) ?? "·";
373
+ const a = args as Record<string, unknown>;
374
+
375
+ let resultStr: string;
376
+ let diff: { prev: string; next: string } | undefined;
377
+
378
+ if (t === "write" && result && typeof result === "object") {
379
+ const r = result as { ok: boolean; prevContent: string | null };
380
+ resultStr = r.ok ? "ok" : "error";
381
+ if (r.ok && typeof a.content === "string") {
382
+ diff = { prev: r.prevContent ?? "", next: a.content };
383
+ }
384
+ } else {
385
+ resultStr = summarizeResult(
386
+ typeof result === "string" ? result : JSON.stringify(result),
387
+ );
388
+ }
389
+
390
+ pushMsg({
391
+ role: "assistant",
392
+ type: "tool",
393
+ toolName: t,
394
+ content: label,
395
+ result: resultStr,
396
+ approved: true,
397
+ diff,
398
+ });
399
+ pendingToolRef.current = null;
400
+ }
401
+ },
402
+ onFinish: (fullText, responseMessages) => {
403
+ if (!abort.signal.aborted) {
404
+ // always save full response messages (includes tool calls) for context
405
+ sessionRef.current = appendMessages(
406
+ sessionRef.current,
407
+ responseMessages,
408
+ );
409
+ if (!single) saveSession(sessionRef.current);
410
+
411
+ if (fullText.trim()) {
412
+ pushMsg({ role: "assistant", type: "text", content: fullText });
413
+ }
414
+ if (single) process.exit(0);
415
+ }
416
+ setCurrentChunk("");
417
+ setStage("idle");
418
+ },
419
+ });
420
+ } catch (err) {
421
+ if (!abort.signal.aborted) {
422
+ const msg = err instanceof Error ? err.message : String(err);
423
+ pushMsg({ role: "assistant", type: "text", content: `Error: ${msg}` });
424
+ }
425
+ setCurrentChunk("");
426
+ setStage("idle");
427
+ }
428
+ };
429
+
430
+ // ── Auto-send initial message ──────────────────────────────────────────────
431
+
432
+ const didAutoSend = useRef(false);
433
+ React.useEffect(() => {
434
+ if (initialMessage && !didAutoSend.current) {
435
+ didAutoSend.current = true;
436
+ sendMessage(initialMessage);
437
+ }
438
+ }, []);
439
+
440
+ // ── Render ─────────────────────────────────────────────────────────────────
441
+
442
+ return (
443
+ <Box flexDirection="column">
444
+ <Static items={HEADER_ITEMS}>
445
+ {(_, i) => (
446
+ <AppHeader key={i} model={getActiveModelName()} repoPath={repoPath} />
447
+ )}
448
+ </Static>
449
+ <Static items={committed}>
450
+ {(msg, i) => <StaticMessage key={i} msg={msg} />}
451
+ </Static>
452
+
453
+ {stage === "thinking" && (
454
+ <Box flexDirection="column">
455
+ {currentChunk ? (
456
+ <Box gap={1}>
457
+ <Text color={ACCENT}>●</Text>
458
+ <MessageBody content={currentChunk} />
459
+ </Box>
460
+ ) : (
461
+ <>
462
+ <Box gap={1}>
463
+ <Text color={ACCENT}>
464
+ <Spinner type="star"></Spinner>
465
+ </Text>
466
+ <Text color={ACCENT}>{thinkingPhrase}</Text>
467
+ </Box>
468
+ <Box marginLeft={2} marginBottom={1}>
469
+ <Text color="gray" dimColor>
470
+ └ tip: {thinkingTip}
471
+ </Text>
472
+ </Box>
473
+ </>
474
+ )}
475
+ </Box>
476
+ )}
477
+
478
+ {approvalRequest && (
479
+ <Box flexDirection="column" marginTop={1} marginLeft={2} gap={0}>
480
+ <Box gap={1}>
481
+ <Text color="yellow">?</Text>
482
+ <Text color={ACCENT}>
483
+ {TOOL_ICONS[approvalRequest.tool] ?? "·"}
484
+ </Text>
485
+ <Text color="white">
486
+ {approvalRequest.label || approvalRequest.tool}
487
+ </Text>
488
+ </Box>
489
+ <Box gap={1} marginLeft={2}>
490
+ <Text color="gray" dimColor>
491
+ allow?
492
+ </Text>
493
+ <Text color={GREEN}>y</Text>
494
+ <Text color="gray" dimColor>
495
+ {" "}
496
+ yes ·{" "}
497
+ </Text>
498
+ <Text color={RED}>n</Text>
499
+ <Text color="gray" dimColor>
500
+ {" "}
501
+ no ·{" "}
502
+ </Text>
503
+ <Text color={ACCENT}>a</Text>
504
+ <Text color="gray" dimColor>
505
+ {" "}
506
+ allow all
507
+ </Text>
508
+ </Box>
509
+ </Box>
510
+ )}
511
+
512
+ {showProvider && (
513
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
514
+ <ProviderSetup
515
+ onDone={() => {
516
+ setShowProvider(false);
517
+ pushMsg({
518
+ role: "assistant",
519
+ type: "text",
520
+ content: `Provider updated. Now using **${getActiveModelName()}**.`,
521
+ });
522
+ }}
523
+ />
524
+ </Box>
525
+ )}
526
+
527
+ {stage === "idle" && !showProvider && (
528
+ <Box flexDirection="column">
529
+ {inputValue.startsWith("/") && <CommandPalette query={inputValue} />}
530
+ <InputBox
531
+ value={inputValue}
532
+ onChange={(v) => setInputValue(v)}
533
+ onSubmit={(val) => {
534
+ if (val.trim()) sendMessage(val.trim());
535
+ clear();
536
+ }}
537
+ inputKey={inputKey}
538
+ />
539
+ </Box>
540
+ )}
541
+
542
+ <ShortcutBar
543
+ autoApprove={autoApprove}
544
+ forceApprove={forceApprove}
545
+ isThinking={isThinking}
546
+ model={getActiveModelName()}
547
+ />
548
+ </Box>
549
+ );
550
+ }
@@ -0,0 +1,152 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { MessageBody } from "@ridit/ink-ui";
4
+ import { ACCENT, GREEN, RED } from "../../colors";
5
+
6
+ // ── Types ─────────────────────────────────────────────────────────────────────
7
+
8
+ export type UIMessage =
9
+ | { role: "user" | "assistant"; type: "text"; content: string }
10
+ | {
11
+ role: "assistant";
12
+ type: "tool";
13
+ toolName: string;
14
+ content: string;
15
+ result: string;
16
+ approved: boolean;
17
+ diff?: { prev: string; next: string };
18
+ };
19
+
20
+ // ── Diff ──────────────────────────────────────────────────────────────────────
21
+
22
+ type DiffLine = { type: "add" | "remove" | "context"; content: string };
23
+
24
+ function computeDiff(prev: string, next: string, context = 2): DiffLine[] {
25
+ const a = prev.split("\n");
26
+ const b = next.split("\n");
27
+ if (a.length > 400 || b.length > 400) {
28
+ return b.map((content) => ({ type: "add" as const, content }));
29
+ }
30
+
31
+ const m = a.length, n = b.length;
32
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0) as number[]);
33
+ for (let i = 1; i <= m; i++)
34
+ for (let j = 1; j <= n; j++)
35
+ dp[i]![j] = a[i - 1] === b[j - 1] ? dp[i - 1]![j - 1]! + 1 : Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
36
+
37
+ const edits: DiffLine[] = [];
38
+ let i = m, j = n;
39
+ while (i > 0 || j > 0) {
40
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
41
+ edits.unshift({ type: "context", content: a[i - 1]! });
42
+ i--; j--;
43
+ } else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
44
+ edits.unshift({ type: "add", content: b[j - 1]! });
45
+ j--;
46
+ } else {
47
+ edits.unshift({ type: "remove", content: a[i - 1]! });
48
+ i--;
49
+ }
50
+ }
51
+
52
+ const keep = new Set<number>();
53
+ edits.forEach((e, idx) => {
54
+ if (e.type !== "context") {
55
+ for (let k = Math.max(0, idx - context); k <= Math.min(edits.length - 1, idx + context); k++)
56
+ keep.add(k);
57
+ }
58
+ });
59
+
60
+ return edits.filter((_, idx) => keep.has(idx));
61
+ }
62
+
63
+ // ── Tool icons ────────────────────────────────────────────────────────────────
64
+
65
+ const TOOL_ICONS: Record<string, string> = {
66
+ bash: "$",
67
+ read: "r",
68
+ write: "w",
69
+ grep: "/",
70
+ ls: "d",
71
+ remember: "·",
72
+ };
73
+
74
+ // ── Static message renderer ───────────────────────────────────────────────────
75
+
76
+ export function StaticMessage({ msg }: { msg: UIMessage }) {
77
+ if (msg.role === "user") {
78
+ return (
79
+ <Box marginBottom={1} gap={1}>
80
+ <Text color={ACCENT}>{">"}</Text>
81
+ <Text color="white" bold>
82
+ {msg.content}
83
+ </Text>
84
+ </Box>
85
+ );
86
+ }
87
+
88
+ if (msg.type === "tool") {
89
+ const icon = TOOL_ICONS[msg.toolName] ?? "·";
90
+
91
+ if (msg.toolName === "write" && msg.diff) {
92
+ const lines = computeDiff(msg.diff.prev, msg.diff.next);
93
+ const additions = lines.filter((l) => l.type === "add").length;
94
+ const deletions = lines.filter((l) => l.type === "remove").length;
95
+ return (
96
+ <Box flexDirection="column" marginBottom={1}>
97
+ <Box gap={1}>
98
+ <Text color={ACCENT}>{icon}</Text>
99
+ <Text color="gray">{msg.content}</Text>
100
+ {lines.length > 0 && (
101
+ <>
102
+ <Text color={GREEN} dimColor>+{additions}</Text>
103
+ <Text color={RED} dimColor>-{deletions}</Text>
104
+ </>
105
+ )}
106
+ </Box>
107
+ <Box flexDirection="column" marginLeft={2}>
108
+ {lines.map((line, i) => (
109
+ <Text
110
+ key={i}
111
+ color={line.type === "add" ? GREEN : line.type === "remove" ? RED : "gray"}
112
+ dimColor={line.type === "context"}
113
+ >
114
+ {line.type === "add" ? "+ " : line.type === "remove" ? "- " : " "}
115
+ {line.content}
116
+ </Text>
117
+ ))}
118
+ </Box>
119
+ </Box>
120
+ );
121
+ }
122
+
123
+ return (
124
+ <Box flexDirection="column" marginBottom={1}>
125
+ <Box gap={1}>
126
+ <Text color={msg.approved ? ACCENT : RED}>{icon}</Text>
127
+ <Text color={msg.approved ? "gray" : RED} dimColor={!msg.approved}>
128
+ {msg.content}
129
+ </Text>
130
+ {!msg.approved && <Text color={RED}>denied</Text>}
131
+ </Box>
132
+ {msg.approved && msg.result && (
133
+ <Box gap={1} marginLeft={2}>
134
+ <Text color="gray" dimColor>{"└"}</Text>
135
+ <Text color="gray" dimColor>
136
+ {msg.result.split("\n")[0]?.slice(0, 120)}
137
+ {(msg.result.split("\n")[0]?.length ?? 0) > 120 ? "…" : ""}
138
+ </Text>
139
+ </Box>
140
+ )}
141
+ </Box>
142
+ );
143
+ }
144
+
145
+ // assistant text
146
+ return (
147
+ <Box marginBottom={1} gap={1}>
148
+ <Text color={ACCENT}>●</Text>
149
+ <MessageBody content={msg.content} />
150
+ </Box>
151
+ );
152
+ }