@ridit/lens 0.2.4 → 0.2.6

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 (41) hide show
  1. package/LENS.md +32 -68
  2. package/README.md +91 -0
  3. package/addons/README.md +3 -0
  4. package/addons/run-tests.js +127 -0
  5. package/dist/index.mjs +226459 -2638
  6. package/package.json +13 -4
  7. package/src/colors.ts +5 -0
  8. package/src/commands/commit.tsx +686 -0
  9. package/src/commands/provider.tsx +36 -22
  10. package/src/components/__tests__/Header.test.tsx +9 -0
  11. package/src/components/chat/ChatMessage.tsx +6 -6
  12. package/src/components/chat/ChatOverlays.tsx +20 -10
  13. package/src/components/chat/ChatRunner.tsx +197 -31
  14. package/src/components/provider/ApiKeyStep.tsx +77 -121
  15. package/src/components/provider/ModelStep.tsx +35 -20
  16. package/src/components/{repo → provider}/ProviderPicker.tsx +1 -1
  17. package/src/components/provider/ProviderTypeStep.tsx +12 -5
  18. package/src/components/provider/RemoveProviderStep.tsx +7 -8
  19. package/src/components/repo/RepoAnalysis.tsx +1 -1
  20. package/src/components/task/TaskRunner.tsx +1 -1
  21. package/src/components/timeline/CommitDetail.tsx +2 -4
  22. package/src/components/timeline/CommitList.tsx +2 -14
  23. package/src/components/timeline/TimelineChat.tsx +1 -2
  24. package/src/components/timeline/TimelineRunner.tsx +506 -423
  25. package/src/index.tsx +38 -0
  26. package/src/prompts/fewshot.ts +144 -47
  27. package/src/prompts/system.ts +25 -21
  28. package/src/tools/chart.ts +210 -0
  29. package/src/tools/convert-image.ts +312 -0
  30. package/src/tools/files.ts +1 -9
  31. package/src/tools/git.ts +577 -0
  32. package/src/tools/index.ts +17 -13
  33. package/src/tools/pdf.ts +136 -78
  34. package/src/tools/view-image.ts +335 -0
  35. package/src/tools/web.ts +0 -4
  36. package/src/utils/addons/loadAddons.ts +6 -3
  37. package/src/utils/chat.ts +38 -23
  38. package/src/utils/thinking.tsx +275 -162
  39. package/src/utils/tools/builtins.ts +39 -32
  40. package/src/utils/tools/registry.ts +0 -14
  41. package/tsconfig.json +2 -2
@@ -1,22 +1,26 @@
1
- import React, { useState, useEffect } from "react";
2
- import { Box, Text, Static, useInput } from "ink";
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { Box, Text, Static, useInput, useStdout } from "ink";
3
3
  import TextInput from "ink-text-input";
4
4
  import { execSync } from "child_process";
5
- import { ProviderPicker } from "../repo/ProviderPicker";
5
+ import { ProviderPicker } from "../provider/ProviderPicker";
6
6
  import {
7
7
  fetchCommits,
8
8
  fetchDiff,
9
9
  isGitRepo,
10
10
  summarizeTimeline,
11
11
  } from "../../utils/git";
12
- import { callChat } from "../../utils/chat";
12
+ import { callChat, parseResponse } from "../../utils/chat";
13
+ import { registry } from "../../utils/tools/registry";
14
+ import { buildGitToolsPromptSection } from "../../tools/git";
13
15
  import type { Commit, DiffFile } from "../../utils/git";
14
16
  import type { Provider } from "../../types/config";
17
+ import type { Message } from "../../types/chat";
18
+ import { TypewriterText, InputBox } from "../chat/ChatOverlays";
19
+ import { ACCENT } from "../../colors";
15
20
 
16
- const ACCENT = "#FF8C00";
17
21
  const W = () => process.stdout.columns ?? 100;
18
22
 
19
- // ── git tool helpers ──────────────────────────────────────────────────────────
23
+ // ── git runner (only used by RevertConfirm) ───────────────────────────────────
20
24
 
21
25
  function gitRun(cmd: string, cwd: string): { ok: boolean; out: string } {
22
26
  try {
@@ -34,59 +38,30 @@ function gitRun(cmd: string, cwd: string): { ok: boolean; out: string } {
34
38
  }
35
39
  }
36
40
 
37
- function getUnstagedDiff(cwd: string): string {
38
- // includes both tracked changes and new untracked files
39
- const tracked = gitRun("git diff HEAD", cwd).out;
40
- const untracked = gitRun(`git ls-files --others --exclude-standard`, cwd).out;
41
-
42
- const untrackedContent = untracked
43
- .split("\n")
44
- .filter(Boolean)
45
- .slice(0, 10)
46
- .map((f) => {
47
- try {
48
- const content = execSync(
49
- `git show :0 "${f}" 2>/dev/null || type "${f}"`,
50
- {
51
- cwd,
52
- encoding: "utf-8",
53
- stdio: ["pipe", "pipe", "pipe"],
54
- },
55
- )
56
- .trim()
57
- .slice(0, 500);
58
- return `=== new file: ${f} ===\n${content}`;
59
- } catch {
60
- return `=== new file: ${f} ===`;
61
- }
62
- })
63
- .join("\n\n");
64
-
65
- return [tracked.slice(0, 4000), untrackedContent]
66
- .filter(Boolean)
67
- .join("\n\n");
68
- }
69
-
70
- async function generateCommitMessage(
71
- provider: Provider,
72
- diff: string,
73
- ): Promise<string> {
74
- const system = `You are a commit message generator. Given a git diff, write a concise, imperative commit message.
75
- Rules:
76
- - First line: short summary, max 72 chars, imperative mood ("add", "fix", "update", not "added")
77
- - If needed, one blank line then a short body (2-3 lines max)
78
- - No markdown, no bullet points, no code blocks
79
- - Output ONLY the commit message, nothing else`;
80
-
81
- const msgs = [
82
- {
83
- role: "user" as const,
84
- content: `Write a commit message for this diff:\n\n${diff}`,
85
- type: "text" as const,
86
- },
87
- ];
88
- const raw = await callChat(provider, system, msgs as any);
89
- return typeof raw === "string" ? raw.trim() : "update files";
41
+ // ── thinking phrases ──────────────────────────────────────────────────────────
42
+
43
+ const THINKING_PHRASES = [
44
+ "thinking…",
45
+ "reading the repo…",
46
+ "consulting the log…",
47
+ "grepping the history…",
48
+ "diffing the vibes…",
49
+ "sniffing the diff...",
50
+ "reading your crimes...",
51
+ "crafting the perfect commit message...",
52
+ "pretending this was intentional all along...",
53
+ "making it sound like a feature...",
54
+ "turning chaos into conventional commits...",
55
+ "72 chars or bust...",
56
+ "git log will remember this...",
57
+ "committing to the bit. and also the repo...",
58
+ "staging your changes (and your career)...",
59
+ "making main proud...",
60
+ "git blame: not it...",
61
+ ];
62
+
63
+ function randomPhrase() {
64
+ return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)]!;
90
65
  }
91
66
 
92
67
  // ── tiny helpers ──────────────────────────────────────────────────────────────
@@ -387,7 +362,6 @@ function RevertConfirm({
387
362
  if (status !== "confirm") return;
388
363
  if (input === "y" || input === "Y" || key.return) {
389
364
  setStatus("running");
390
- // use revert (safe — creates a new commit, doesn't rewrite history)
391
365
  const r = gitRun(`git revert --no-edit "${commit.hash}"`, repoPath);
392
366
  setResult(r.out);
393
367
  setStatus("done");
@@ -455,341 +429,407 @@ function RevertConfirm({
455
429
  );
456
430
  }
457
431
 
458
- // ── CommitPanel — stage + commit unstaged changes ─────────────────────────────
432
+ // ── MsgBody ───────────────────────────────────────────────────────────────────
433
+ // Mirrors MessageBody from ChatMessage.tsx — inline code, bold, lists, code blocks.
459
434
 
460
- type CommitPanelState =
461
- | { phase: "scanning" }
462
- | { phase: "no-changes" }
463
- | { phase: "generating"; diff: string }
464
- | { phase: "review"; diff: string; message: string }
465
- | { phase: "editing"; diff: string; message: string }
466
- | { phase: "committing"; message: string }
467
- | { phase: "done"; result: string }
468
- | { phase: "error"; message: string };
435
+ function InlineText({ text }: { text: string }) {
436
+ const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g);
437
+ return (
438
+ <>
439
+ {parts.map((part, i) => {
440
+ if (part.startsWith("`") && part.endsWith("`"))
441
+ return (
442
+ <Text key={i} color={ACCENT}>
443
+ {part.slice(1, -1)}
444
+ </Text>
445
+ );
446
+ if (part.startsWith("**") && part.endsWith("**"))
447
+ return (
448
+ <Text key={i} bold color="white">
449
+ {part.slice(2, -2)}
450
+ </Text>
451
+ );
452
+ return (
453
+ <Text key={i} color="white">
454
+ {part}
455
+ </Text>
456
+ );
457
+ })}
458
+ </>
459
+ );
460
+ }
469
461
 
470
- function CommitPanel({
462
+ function MsgBody({ content }: { content: string }) {
463
+ const segments = content.split(/(```[\s\S]*?```)/g);
464
+ return (
465
+ <Box flexDirection="column">
466
+ {segments.map((seg, si) => {
467
+ if (seg.startsWith("```")) {
468
+ const lines = seg.slice(3).split("\n");
469
+ const code = lines
470
+ .slice(1)
471
+ .join("\n")
472
+ .replace(/```\s*$/, "")
473
+ .trimEnd();
474
+ return (
475
+ <Box key={si} flexDirection="column">
476
+ {code.split("\n").map((line, li) => (
477
+ <Text key={li} color={ACCENT}>
478
+ {" "}
479
+ {line}
480
+ </Text>
481
+ ))}
482
+ </Box>
483
+ );
484
+ }
485
+ const lines = seg.split("\n").filter((l) => l.trim() !== "");
486
+ return (
487
+ <Box key={si} flexDirection="column">
488
+ {lines.map((line, li) => {
489
+ if (line.match(/^[-*•]\s/))
490
+ return (
491
+ <Box key={li} gap={1}>
492
+ <Text color={ACCENT}>*</Text>
493
+ <InlineText text={line.slice(2).trim()} />
494
+ </Box>
495
+ );
496
+ if (line.match(/^\d+\.\s/)) {
497
+ const num = line.match(/^(\d+)\.\s/)![1];
498
+ return (
499
+ <Box key={li} gap={1}>
500
+ <Text color="gray">{num}.</Text>
501
+ <InlineText text={line.replace(/^\d+\.\s/, "").trim()} />
502
+ </Box>
503
+ );
504
+ }
505
+ return (
506
+ <Box key={li}>
507
+ <InlineText text={line} />
508
+ </Box>
509
+ );
510
+ })}
511
+ </Box>
512
+ );
513
+ })}
514
+ </Box>
515
+ );
516
+ }
517
+
518
+ // ── AskPanel ──────────────────────────────────────────────────────────────────
519
+ //
520
+ // Uses the global registry + parseResponse — identical execution path to
521
+ // ChatRunner.processResponse. Git tools come from tools/git.ts which registers
522
+ // them into the registry at startup. No local tool definitions here.
523
+
524
+ type AskMsg =
525
+ | { kind: "user"; content: string }
526
+ | { kind: "assistant"; content: string }
527
+ | { kind: "thinking" }
528
+ | { kind: "image"; ansi: string }
529
+ | {
530
+ kind: "tool";
531
+ toolName: string;
532
+ label: string;
533
+ result?: string;
534
+ approved?: boolean;
535
+ };
536
+
537
+ type PendingTool = {
538
+ toolName: string;
539
+ input: unknown;
540
+ rawInput: string;
541
+ remainder: string | undefined;
542
+ history: Message[];
543
+ };
544
+
545
+ function AskPanel({
546
+ commits,
471
547
  repoPath,
472
548
  provider,
473
- onDone,
549
+ onReload,
474
550
  }: {
551
+ commits: Commit[];
475
552
  repoPath: string;
476
553
  provider: Provider;
477
- onDone: (msg: string | null) => void;
554
+ onReload: () => void;
478
555
  }) {
479
- const [state, setState] = useState<CommitPanelState>({ phase: "scanning" });
556
+ const [messages, setMessages] = useState<AskMsg[]>([]);
557
+ const [apiHistory, setApiHistory] = useState<Message[]>([]);
558
+ const [input, setInput] = useState("");
559
+ const [thinking, setThinking] = useState(false);
560
+ const [phrase, setPhrase] = useState(randomPhrase);
561
+ const [pending, setPending] = useState<PendingTool | null>(null);
562
+ const abortRef = useRef<AbortController | null>(null);
563
+ const { stdout } = useStdout();
480
564
 
481
- // scan + generate on mount
565
+ // Rotate thinking phrase while busy
482
566
  useEffect(() => {
483
- const diff = getUnstagedDiff(repoPath);
484
- if (!diff.trim() || diff === "(done)") {
485
- setState({ phase: "no-changes" });
567
+ if (!thinking) return;
568
+ setPhrase(randomPhrase());
569
+ const id = setInterval(() => setPhrase(randomPhrase()), 3200);
570
+ return () => clearInterval(id);
571
+ }, [thinking]);
572
+
573
+ const systemPrompt = `You are a git assistant embedded in a terminal timeline viewer.
574
+ Repository: ${repoPath}
575
+
576
+ You have access to git tools to answer questions and perform git operations.
577
+ ${buildGitToolsPromptSection()}
578
+
579
+ Rules:
580
+ - Use read tools freely to answer questions requiring live data
581
+ - For write operations briefly explain what you are about to do before emitting the tag
582
+ - After a tool result is returned, continue your response naturally
583
+ - Plain text only — no markdown headers
584
+ - Be concise
585
+
586
+ Timeline summary (last 300 commits):
587
+ ${summarizeTimeline(commits)}`;
588
+
589
+ // ── core process loop — mirrors ChatRunner.processResponse ─────────────────
590
+
591
+ const processResponse = (
592
+ raw: string,
593
+ currentHistory: Message[],
594
+ signal: AbortSignal,
595
+ ) => {
596
+ if (signal.aborted) {
597
+ setThinking(false);
486
598
  return;
487
599
  }
488
- setState({ phase: "generating", diff });
489
- generateCommitMessage(provider, diff)
490
- .then((msg) => setState({ phase: "review", diff, message: msg }))
491
- .catch((e) => setState({ phase: "error", message: String(e) }));
492
- }, []);
493
600
 
494
- useInput((input, key) => {
495
- if (
496
- state.phase === "no-changes" ||
497
- state.phase === "scanning" ||
498
- state.phase === "generating"
499
- ) {
500
- if (key.escape || input === "n" || input === "N") onDone(null);
601
+ const parsed = parseResponse(raw);
602
+
603
+ // plain text
604
+ if (parsed.kind === "text") {
605
+ const clean = parsed.content.replace(/\*\*([^*]+)\*\*/g, "$1").trim();
606
+ setMessages((prev) => [
607
+ ...prev.filter((m) => m.kind !== "thinking"),
608
+ { kind: "assistant", content: clean },
609
+ ]);
610
+ setApiHistory([
611
+ ...currentHistory,
612
+ { role: "assistant", content: clean, type: "text" },
613
+ ]);
614
+ setThinking(false);
501
615
  return;
502
616
  }
503
617
 
504
- if (state.phase === "review") {
505
- if (input === "y" || input === "Y" || key.return) {
506
- // commit
507
- setState({ phase: "committing", message: state.message });
508
- const add = gitRun("git add -A", repoPath);
509
- if (!add.ok) {
510
- setState({ phase: "error", message: add.out });
511
- return;
512
- }
513
- const commit = gitRun(
514
- `git commit -m ${JSON.stringify(state.message)}`,
515
- repoPath,
516
- );
517
- setState({
518
- phase: "done",
519
- result: commit.ok ? commit.out : `Error: ${commit.out}`,
520
- });
521
- setTimeout(() => onDone(commit.ok ? state.message : null), 1500);
522
- return;
523
- }
524
- if (input === "e" || input === "E") {
525
- setState({
526
- phase: "editing",
527
- diff: state.diff,
528
- message: state.message,
529
- });
530
- return;
531
- }
532
- if (input === "n" || input === "N" || key.escape) {
533
- onDone(null);
618
+ // tool call
619
+ if (parsed.kind === "tool") {
620
+ const tool = registry.get(parsed.toolName);
621
+ if (!tool) {
622
+ setThinking(false);
534
623
  return;
535
624
  }
536
- }
537
625
 
538
- if (state.phase === "editing") {
539
- if (key.escape) {
540
- setState({ phase: "review", diff: state.diff, message: state.message });
626
+ const label = tool.summariseInput
627
+ ? String(tool.summariseInput(parsed.input))
628
+ : parsed.rawInput;
629
+
630
+ if (tool.safe) {
631
+ // Auto-approve — keep thinking true the whole time so input stays locked.
632
+ // Replace the thinking bubble with preamble (if any) + tool row + new thinking bubble.
633
+ setMessages((prev) => [
634
+ ...prev.filter((m) => m.kind !== "thinking"),
635
+ ...(parsed.content
636
+ ? [{ kind: "assistant" as const, content: parsed.content }]
637
+ : []),
638
+ {
639
+ kind: "tool" as const,
640
+ toolName: parsed.toolName,
641
+ label,
642
+ approved: true,
643
+ },
644
+ { kind: "thinking" as const },
645
+ ]);
646
+ executeAndContinue(
647
+ {
648
+ toolName: parsed.toolName,
649
+ input: parsed.input,
650
+ rawInput: parsed.rawInput,
651
+ remainder: parsed.remainder,
652
+ history: currentHistory,
653
+ },
654
+ true,
655
+ signal,
656
+ );
657
+ } else {
658
+ // Write tool — stop thinking, show permission prompt, block input via pending.
659
+ setThinking(false);
660
+ setMessages((prev) => [
661
+ ...prev.filter((m) => m.kind !== "thinking"),
662
+ ...(parsed.content
663
+ ? [{ kind: "assistant" as const, content: parsed.content }]
664
+ : []),
665
+ { kind: "tool" as const, toolName: parsed.toolName, label },
666
+ ]);
667
+ setPending({
668
+ toolName: parsed.toolName,
669
+ input: parsed.input,
670
+ rawInput: parsed.rawInput,
671
+ remainder: parsed.remainder,
672
+ history: currentHistory,
673
+ });
541
674
  }
542
- // TextInput handles the rest
675
+ return;
543
676
  }
544
677
 
545
- if (state.phase === "done" || state.phase === "error") {
546
- if (key.return || key.escape) onDone(null);
547
- }
548
- });
549
-
550
- const w = W();
551
- const divider = "─".repeat(w);
552
-
553
- return (
554
- <Box flexDirection="column" marginTop={1}>
555
- <Text color="gray" dimColor>
556
- {divider}
557
- </Text>
558
- <Box paddingX={1} marginBottom={1} gap={2}>
559
- <Text color={ACCENT} bold>
560
- COMMIT CHANGES
561
- </Text>
562
- </Box>
563
-
564
- {state.phase === "scanning" && (
565
- <Box paddingX={1} gap={1}>
566
- <Text color={ACCENT}>*</Text>
567
- <Text color="gray" dimColor>
568
- scanning for changes…
569
- </Text>
570
- </Box>
571
- )}
572
-
573
- {state.phase === "no-changes" && (
574
- <Box paddingX={1} flexDirection="column" gap={1}>
575
- <Box gap={1}>
576
- <Text color="yellow">!</Text>
577
- <Text color="white">no uncommitted changes found</Text>
578
- </Box>
579
- <Text color="gray" dimColor>
580
- {" "}
581
- esc to close
582
- </Text>
583
- </Box>
584
- )}
585
-
586
- {state.phase === "generating" && (
587
- <Box paddingX={1} gap={1}>
588
- <Text color={ACCENT}>*</Text>
589
- <Text color="gray" dimColor>
590
- generating commit message…
591
- </Text>
592
- </Box>
593
- )}
678
+ // anything else (changes, clone) show as text in this context
679
+ setMessages((prev) => [
680
+ ...prev.filter((m) => m.kind !== "thinking"),
681
+ { kind: "assistant", content: raw.trim() },
682
+ ]);
683
+ setThinking(false);
684
+ };
594
685
 
595
- {(state.phase === "review" || state.phase === "editing") && (
596
- <Box paddingX={1} flexDirection="column" gap={1}>
597
- {/* show a compact diff summary */}
598
- <Box gap={1}>
599
- <Text color="gray" dimColor>
600
- diff preview:
601
- </Text>
602
- <Text color="gray" dimColor>
603
- {trunc(state.diff.split("\n")[0] ?? "", w - 20)}
604
- </Text>
605
- </Box>
606
- <Box gap={1} marginTop={1}>
607
- <Text color="gray" dimColor>
608
- message:
609
- </Text>
610
- </Box>
686
+ const executeAndContinue = async (
687
+ p: PendingTool,
688
+ approved: boolean,
689
+ signal: AbortSignal,
690
+ ) => {
691
+ const tool = registry.get(p.toolName);
692
+ if (!tool) return;
611
693
 
612
- {state.phase === "review" && (
613
- <Box paddingLeft={2} flexDirection="column">
614
- <Text color="white" bold wrap="wrap">
615
- {state.message}
616
- </Text>
617
- <Box gap={3} marginTop={1}>
618
- <Text color="green">y/enter commit</Text>
619
- <Text color="cyan">e edit</Text>
620
- <Text color="gray" dimColor>
621
- n/esc cancel
622
- </Text>
623
- </Box>
624
- </Box>
625
- )}
694
+ let result = "(denied by user)";
695
+ let resultKind: string = "text";
626
696
 
627
- {state.phase === "editing" && (
628
- <Box paddingLeft={2} flexDirection="column" gap={1}>
629
- <TextInput
630
- value={state.message}
631
- onChange={(msg) =>
632
- setState({ phase: "editing", diff: state.diff, message: msg })
633
- }
634
- onSubmit={(msg) =>
635
- setState({ phase: "review", diff: state.diff, message: msg })
636
- }
637
- />
638
- <Text color="gray" dimColor>
639
- enter to confirm · esc to cancel edit
640
- </Text>
641
- </Box>
642
- )}
643
- </Box>
644
- )}
645
-
646
- {state.phase === "committing" && (
647
- <Box paddingX={1} gap={1}>
648
- <Text color={ACCENT}>*</Text>
649
- <Text color="gray" dimColor>
650
- committing…
651
- </Text>
652
- </Box>
653
- )}
697
+ if (approved) {
698
+ try {
699
+ const toolResult = await tool.execute(p.input, {
700
+ repoPath,
701
+ messages: p.history,
702
+ });
703
+ result = toolResult.value;
704
+ resultKind = (toolResult as any).kind ?? "text";
705
+ } catch (e: any) {
706
+ result = `Error: ${e.message}`;
707
+ }
708
+ }
654
709
 
655
- {state.phase === "done" && (
656
- <Box paddingX={1} gap={1}>
657
- <Text color="green">✓</Text>
658
- <Text color="white" wrap="wrap">
659
- {trunc(state.result, w - 6)}
660
- </Text>
661
- </Box>
662
- )}
710
+ // Image result write ANSI directly to stdout (bypasses Ink's renderer)
711
+ // and inject an image message into the list instead of a text result.
712
+ if (resultKind === "image" && approved) {
713
+ setMessages((prev) => {
714
+ const next = prev
715
+ .map((m) =>
716
+ m.kind === "tool" &&
717
+ m.toolName === p.toolName &&
718
+ m.result === undefined
719
+ ? { ...m, result: "(image)", approved }
720
+ : m,
721
+ )
722
+ .filter((m) => m.kind !== "thinking");
723
+ return [...next, { kind: "image" as const, ansi: result }];
724
+ });
725
+ stdout.write(result + "\n");
726
+ } else {
727
+ // Stamp result onto the tool bubble and remove the trailing thinking bubble
728
+ // in one atomic update — no intermediate render with a dangling spinner.
729
+ setMessages((prev) => {
730
+ const next = prev
731
+ .map((m) =>
732
+ m.kind === "tool" &&
733
+ m.toolName === p.toolName &&
734
+ m.result === undefined
735
+ ? { ...m, result, approved }
736
+ : m,
737
+ )
738
+ .filter((m) => m.kind !== "thinking");
739
+ return next;
740
+ });
741
+ }
663
742
 
664
- {state.phase === "error" && (
665
- <Box paddingX={1} flexDirection="column" gap={1}>
666
- <Box gap={1}>
667
- <Text color="red">✗</Text>
668
- <Text color="white" wrap="wrap">
669
- {trunc(state.message, w - 6)}
670
- </Text>
671
- </Box>
672
- <Text color="gray" dimColor>
673
- {" "}
674
- enter/esc to close
675
- </Text>
676
- </Box>
677
- )}
678
- </Box>
679
- );
680
- }
743
+ // reload commit list if a write succeeded
744
+ if (
745
+ approved &&
746
+ !result.startsWith("Error") &&
747
+ !result.startsWith("(denied")
748
+ ) {
749
+ onReload();
750
+ }
681
751
 
682
- // ── AskPanel ──────────────────────────────────────────────────────────────────
683
- // No Static here — Static always floats to the top of Ink output regardless of
684
- // where it is placed in the tree. We use plain state arrays instead so messages
685
- // render in document flow, below the commit list.
752
+ const nextHistory: Message[] = [
753
+ ...p.history,
754
+ {
755
+ role: "user" as const,
756
+ content: approved
757
+ ? `Tool result for <${p.toolName}>:\n${result}`
758
+ : `Tool <${p.toolName}> was denied by the user.`,
759
+ type: "text" as const,
760
+ },
761
+ ];
762
+ setApiHistory(nextHistory);
686
763
 
687
- type ChatMsg =
688
- | { role: "user"; content: string }
689
- | { role: "assistant"; content: string }
690
- | { role: "thinking" };
764
+ // if the model already wrote a remainder, process it inline
765
+ if (approved && p.remainder) {
766
+ processResponse(p.remainder, nextHistory, signal);
767
+ return;
768
+ }
691
769
 
692
- function AskPanel({
693
- commits,
694
- provider,
695
- onCommit,
696
- }: {
697
- commits: Commit[];
698
- provider: Provider;
699
- onCommit: () => void;
700
- }) {
701
- const [messages, setMessages] = useState<ChatMsg[]>([]);
702
- const [input, setInput] = useState("");
703
- const [thinking, setThinking] = useState(false);
704
- const [history, setHistory] = useState<
705
- { role: "user" | "assistant"; content: string; type: "text" }[]
706
- >([]);
707
-
708
- // keywords that mean "commit my changes" in any language
709
- const COMMIT_TRIGGERS = [
710
- /commit/i,
711
- /stage/i,
712
- /push changes/i,
713
- // hinglish / hindi
714
- /commit kr/i,
715
- /commit kar/i,
716
- /changes commit/i,
717
- /changes save/i,
718
- /save changes/i,
719
- /badlav.*commit/i,
720
- ];
721
-
722
- const systemPrompt = `You are a git history analyst embedded in a terminal git timeline viewer.
723
- You can ONLY answer questions about the git history shown below.
724
- You CANNOT run commands, execute git operations, or modify files.
725
- If the user asks to commit, stage, push, or make any git change — reply with exactly: DELEGATE_COMMIT
726
- Plain text answers only. No markdown. No code blocks. No backticks. Be concise.
770
+ // no remainder — follow-up API call.
771
+ // Set thinking BEFORE the stamp so isBusy never drops to false between
772
+ // the tool completing and the next runChat starting.
773
+ setThinking(true);
774
+ setMessages((prev) => [...prev, { kind: "thinking" }]);
775
+ runChat(nextHistory, signal);
776
+ };
727
777
 
728
- ${summarizeTimeline(commits)}`;
778
+ const runChat = async (history: Message[], signal: AbortSignal) => {
779
+ try {
780
+ const raw = await callChat(provider, systemPrompt, history, signal);
781
+ if (signal.aborted) return;
782
+ processResponse(raw, history, signal);
783
+ } catch (e: any) {
784
+ if (e?.name === "AbortError") return;
785
+ setMessages((prev) => [
786
+ ...prev.filter((m) => m.kind !== "thinking"),
787
+ { kind: "assistant", content: `Error: ${String(e)}` },
788
+ ]);
789
+ setThinking(false);
790
+ }
791
+ };
729
792
 
730
793
  const ask = async (q: string) => {
731
- if (!q.trim() || thinking) return;
794
+ if (!q.trim() || thinking || pending !== null) return;
732
795
 
733
- // client-side check first catch obvious commit intents without an API call
734
- if (COMMIT_TRIGGERS.some((re) => re.test(q))) {
735
- setMessages((prev) => [...prev, { role: "user", content: q }]);
736
- setInput("");
737
- onCommit();
738
- return;
739
- }
796
+ const userMsg: Message = { role: "user", content: q, type: "text" };
797
+ const nextHistory = [...apiHistory, userMsg];
740
798
 
741
- const nextHistory = [
742
- ...history,
743
- { role: "user" as const, content: q, type: "text" as const },
744
- ];
799
+ // Set thinking true FIRST so isBusy blocks input before the next render
800
+ setThinking(true);
745
801
  setMessages((prev) => [
746
802
  ...prev,
747
- { role: "user", content: q },
748
- { role: "thinking" },
803
+ { kind: "user", content: q },
804
+ { kind: "thinking" },
749
805
  ]);
750
- setThinking(true);
806
+ setApiHistory(nextHistory);
751
807
  setInput("");
752
- try {
753
- const raw = await callChat(provider, systemPrompt, nextHistory as any);
754
- const answer = typeof raw === "string" ? raw.trim() : "(no response)";
755
-
756
- // model-side delegation signal
757
- if (
758
- answer === "DELEGATE_COMMIT" ||
759
- answer.startsWith("DELEGATE_COMMIT")
760
- ) {
761
- setMessages((prev) => prev.filter((m) => m.role !== "thinking"));
762
- setThinking(false);
763
- onCommit();
764
- return;
765
- }
766
808
 
767
- // strip any accidental markdown/code blocks the model snuck in
768
- const clean = answer
769
- .replace(/```[\s\S]*?```/g, "")
770
- .replace(/`([^`]+)`/g, "$1")
771
- .replace(/\*\*([^*]+)\*\*/g, "$1")
772
- .trim();
809
+ const abort = new AbortController();
810
+ abortRef.current = abort;
811
+ await runChat(nextHistory, abort.signal);
812
+ };
773
813
 
774
- setMessages((prev) => [
775
- ...prev.filter((m) => m.role !== "thinking"),
776
- { role: "assistant", content: clean },
777
- ]);
778
- setHistory([
779
- ...nextHistory,
780
- { role: "assistant", content: clean, type: "text" },
781
- ]);
782
- } catch (e) {
783
- setMessages((prev) => [
784
- ...prev.filter((m) => m.role !== "thinking"),
785
- { role: "assistant", content: `Error: ${String(e)}` },
786
- ]);
787
- } finally {
788
- setThinking(false);
814
+ // permission y/n — only fires when pending !== null
815
+ useInput((inp, key) => {
816
+ if (!pending) return;
817
+ if (inp === "y" || inp === "Y" || key.return) {
818
+ const p = pending;
819
+ setPending(null);
820
+ const abort = abortRef.current ?? new AbortController();
821
+ executeAndContinue(p, true, abort.signal);
822
+ } else if (inp === "n" || inp === "N" || key.escape) {
823
+ const p = pending;
824
+ setPending(null);
825
+ const abort = abortRef.current ?? new AbortController();
826
+ executeAndContinue(p, false, abort.signal);
789
827
  }
790
- };
828
+ });
791
829
 
792
830
  const w = W();
831
+ const isBusy = thinking || pending !== null;
832
+ const hasThinking = messages.some((m) => m.kind === "thinking");
793
833
 
794
834
  return (
795
835
  <Box flexDirection="column" marginTop={1}>
@@ -797,50 +837,120 @@ ${summarizeTimeline(commits)}`;
797
837
  {"─".repeat(w)}
798
838
  </Text>
799
839
 
800
- {/* plain array render — stays in document flow below the commit list */}
840
+ <Box paddingX={1} marginBottom={1} gap={2}>
841
+ <Text color={ACCENT} bold>
842
+ ASK
843
+ </Text>
844
+ <Text color="gray" dimColor>
845
+ git tools available · y/n for writes · esc back
846
+ </Text>
847
+ </Box>
848
+
801
849
  {messages.map((msg, i) => {
802
- if (msg.role === "thinking")
850
+ // ── thinking ────────────────────────────────────────────────────
851
+ if (msg.kind === "thinking") {
852
+ // Only render the last thinking bubble; use phrase state directly
853
+ const lastIdx = messages.map((m) => m.kind).lastIndexOf("thinking");
854
+ if (i !== lastIdx) return null;
803
855
  return (
804
- <Box key={i} paddingX={1} gap={1}>
805
- <Text color={ACCENT}>*</Text>
806
- <Text color="gray" dimColor>
807
- thinking…
808
- </Text>
856
+ <Box key="thinking" gap={1} marginBottom={1}>
857
+ <Text color={ACCENT}>●</Text>
858
+ <TypewriterText key={phrase} text={phrase} />
809
859
  </Box>
810
860
  );
811
- if (msg.role === "user")
861
+ }
862
+
863
+ // ── user ────────────────────────────────────────────────────────
864
+ if (msg.kind === "user") {
812
865
  return (
813
- <Box key={i} paddingX={1} gap={1}>
866
+ <Box
867
+ key={i}
868
+ marginBottom={1}
869
+ gap={1}
870
+ backgroundColor="#1a1a1a"
871
+ paddingLeft={1}
872
+ paddingRight={2}
873
+ >
814
874
  <Text color="gray">{">"}</Text>
815
- <Text color="white">{msg.content}</Text>
875
+ <Text color="white" bold>
876
+ {msg.content}
877
+ </Text>
878
+ </Box>
879
+ );
880
+ }
881
+
882
+ // ── tool ────────────────────────────────────────────────────────
883
+ if (msg.kind === "tool") {
884
+ const isDone = msg.result !== undefined;
885
+ const denied = msg.approved === false;
886
+ const isError = msg.result?.startsWith("Error") || denied;
887
+ const tool = registry.get(msg.toolName);
888
+ const isWrite = tool && !tool.safe;
889
+ return (
890
+ <Box key={i} flexDirection="column" marginBottom={1}>
891
+ <Box gap={1}>
892
+ <Text color={denied ? "red" : ACCENT}>$</Text>
893
+ <Text color={denied ? "red" : "gray"} dimColor={!denied}>
894
+ {trunc(msg.label, w - 4)}
895
+ </Text>
896
+ {denied && <Text color="red">denied</Text>}
897
+ </Box>
898
+ {!isDone && isWrite && (
899
+ <Box marginLeft={2} gap={1}>
900
+ <Text color="gray">y/enter allow · n/esc deny</Text>
901
+ </Box>
902
+ )}
903
+ {isDone && msg.result && (
904
+ <Box marginLeft={2}>
905
+ <Text color={isError ? "red" : "gray"} dimColor={!isError}>
906
+ {trunc(msg.result.split("\n")[0]!, w - 6)}
907
+ {(msg.result.split("\n")[0]?.length ?? 0) > w - 6
908
+ ? "…"
909
+ : ""}
910
+ </Text>
911
+ </Box>
912
+ )}
913
+ </Box>
914
+ );
915
+ }
916
+
917
+ // ── image ────────────────────────────────────────────────────────
918
+ // Already written to stdout raw — just show a placeholder label so
919
+ // the message list stays coherent and the image appears above it.
920
+ if (msg.kind === "image") {
921
+ return (
922
+ <Box key={i} gap={1} marginBottom={1}>
923
+ <Text color={ACCENT}>◎</Text>
924
+ <Text color="gray" dimColor>
925
+ image rendered above
926
+ </Text>
816
927
  </Box>
817
928
  );
929
+ }
930
+
931
+ // ── assistant ───────────────────────────────────────────────────
818
932
  return (
819
- <Box key={i} paddingX={1} gap={1} marginBottom={1}>
820
- <Text color={ACCENT}>{"*"}</Text>
821
- <Text color="white" wrap="wrap">
822
- {msg.content}
823
- </Text>
933
+ <Box key={i} marginBottom={1} gap={1}>
934
+ <Text color={ACCENT}>●</Text>
935
+ <MsgBody content={msg.content} />
824
936
  </Box>
825
937
  );
826
938
  })}
827
939
 
828
- {/* input always at the bottom of the panel */}
829
- <Box paddingX={1} gap={1}>
830
- <Text color={ACCENT}>{"?"}</Text>
831
- {!thinking ? (
832
- <TextInput
833
- value={input}
834
- onChange={setInput}
835
- onSubmit={ask}
836
- placeholder="ask about the history…"
837
- />
838
- ) : (
839
- <Text color="gray" dimColor>
840
- thinking…
841
- </Text>
842
- )}
843
- </Box>
940
+ {pending && (
941
+ <Box marginLeft={2} gap={1} marginBottom={1}>
942
+ <Text color="gray">y/enter allow · n/esc deny</Text>
943
+ </Box>
944
+ )}
945
+
946
+ <InputBox
947
+ value={input}
948
+ onChange={setInput}
949
+ onSubmit={(v) => {
950
+ if (v.trim()) ask(v.trim());
951
+ }}
952
+ inputKey={isBusy ? 1 : 0}
953
+ />
844
954
  </Box>
845
955
  );
846
956
  }
@@ -851,8 +961,7 @@ type UIMode =
851
961
  | { type: "browse" }
852
962
  | { type: "search"; query: string }
853
963
  | { type: "ask" }
854
- | { type: "revert"; commit: Commit }
855
- | { type: "commit" };
964
+ | { type: "revert"; commit: Commit };
856
965
 
857
966
  type StatusMsg = { id: number; text: string; ok: boolean };
858
967
  let sid = 0;
@@ -964,12 +1073,7 @@ export function TimelineRunner({
964
1073
  else process.exit(0);
965
1074
  }
966
1075
 
967
- // overlays consume all input except ctrl+c
968
- if (
969
- mode.type === "ask" ||
970
- mode.type === "revert" ||
971
- mode.type === "commit"
972
- ) {
1076
+ if (mode.type === "ask" || mode.type === "revert") {
973
1077
  if (key.escape) setMode({ type: "browse" });
974
1078
  return;
975
1079
  }
@@ -979,7 +1083,6 @@ export function TimelineRunner({
979
1083
  return;
980
1084
  }
981
1085
 
982
- // diff open
983
1086
  if (showDiff) {
984
1087
  if (key.escape || input === "d") {
985
1088
  setShowDiff(false);
@@ -1012,25 +1115,18 @@ export function TimelineRunner({
1012
1115
  setMode({ type: "search", query: "" });
1013
1116
  return;
1014
1117
  }
1015
- if (input === "?") {
1118
+ if (input === "?" || input === "a" || input === "A") {
1016
1119
  setMode({ type: "ask" });
1017
1120
  return;
1018
1121
  }
1019
- if (input === "c" || input === "C") {
1020
- setMode({ type: "commit" });
1021
- return;
1022
- }
1023
-
1024
1122
  if (key.return && selected) {
1025
1123
  setShowDiff(true);
1026
1124
  return;
1027
1125
  }
1028
-
1029
1126
  if (input === "x" || input === "X") {
1030
1127
  if (selected) setMode({ type: "revert", commit: selected });
1031
1128
  return;
1032
1129
  }
1033
-
1034
1130
  if (key.upArrow) {
1035
1131
  const next = Math.max(0, selectedIdx - 1);
1036
1132
  setSelectedIdx(next);
@@ -1038,7 +1134,6 @@ export function TimelineRunner({
1038
1134
  if (next < scrollOffset) setScrollOffset(next);
1039
1135
  return;
1040
1136
  }
1041
-
1042
1137
  if (key.downArrow) {
1043
1138
  const next = Math.min(filtered.length - 1, selectedIdx + 1);
1044
1139
  setSelectedIdx(next);
@@ -1069,7 +1164,6 @@ export function TimelineRunner({
1069
1164
  const isSearching = mode.type === "search";
1070
1165
  const isAsking = mode.type === "ask";
1071
1166
  const isReverting = mode.type === "revert";
1072
- const isCommitting = mode.type === "commit";
1073
1167
  const searchQuery = isSearching ? mode.query : "";
1074
1168
  const visible = filtered.slice(scrollOffset, scrollOffset + visibleCount);
1075
1169
 
@@ -1078,10 +1172,10 @@ export function TimelineRunner({
1078
1172
  : isSearching
1079
1173
  ? "type to filter · enter confirm · esc cancel"
1080
1174
  : isAsking
1081
- ? "type question · enter send · esc close"
1082
- : isReverting || isCommitting
1083
- ? "see prompt above · esc cancel"
1084
- : `↑↓ navigate · enter diff · x revert · c commit · / search · ? ask${onExit ? " · q back" : " · ^C exit"}`;
1175
+ ? "ask anything · git tools available · esc back"
1176
+ : isReverting
1177
+ ? "y confirm · n/esc cancel"
1178
+ : `↑↓ navigate · enter diff · x revert · a ask · / search${onExit ? " · q back" : " · ^C exit"}`;
1085
1179
 
1086
1180
  return (
1087
1181
  <Box flexDirection="column">
@@ -1101,7 +1195,7 @@ export function TimelineRunner({
1101
1195
  )}
1102
1196
  </Box>
1103
1197
 
1104
- {/* status messages (Static — no re-render) */}
1198
+ {/* status messages */}
1105
1199
  <Static items={statusMsgs}>
1106
1200
  {(msg) => (
1107
1201
  <Box key={msg.id} paddingX={1} gap={1}>
@@ -1167,21 +1261,8 @@ export function TimelineRunner({
1167
1261
  if (msg) {
1168
1262
  addStatus(msg, true);
1169
1263
  reloadCommits();
1170
- } else addStatus("revert cancelled", false);
1171
- }}
1172
- />
1173
- )}
1174
-
1175
- {/* commit overlay */}
1176
- {isCommitting && provider && (
1177
- <CommitPanel
1178
- repoPath={repoPath}
1179
- provider={provider}
1180
- onDone={(msg) => {
1181
- setMode({ type: "browse" });
1182
- if (msg) {
1183
- addStatus(`committed: ${trunc(msg, 60)}`, true);
1184
- reloadCommits();
1264
+ } else {
1265
+ addStatus("revert cancelled", false);
1185
1266
  }
1186
1267
  }}
1187
1268
  />
@@ -1191,9 +1272,11 @@ export function TimelineRunner({
1191
1272
  {isAsking && provider && (
1192
1273
  <AskPanel
1193
1274
  commits={commits}
1275
+ repoPath={repoPath}
1194
1276
  provider={provider}
1195
- onCommit={() => {
1196
- setMode({ type: "commit" });
1277
+ onReload={() => {
1278
+ reloadCommits();
1279
+ addStatus("commits reloaded", true);
1197
1280
  }}
1198
1281
  />
1199
1282
  )}