|
1 | | -import { isAbsolute, sep as pathSep, relative as relativePath, resolve as resolveAbsolute } from "node:path"; |
2 | 1 | import type { AgentMessage } from "@earendil-works/pi-agent-core"; |
3 | 2 | import { diffLines, diffWordsWithSpace } from "diff"; |
4 | 3 | import { Box, Text } from "ink"; |
5 | 4 | import { type ReactNode, useEffect, useState } from "react"; |
6 | 5 | import type { ToolExecution } from "../types.js"; |
7 | 6 | import { Markdown } from "./Markdown.js"; |
| 7 | +import { displayPath } from "./paths.js"; |
| 8 | +import { |
| 9 | + nounForReadTool, |
| 10 | + pastVerbForReadTool, |
| 11 | + presentVerbForReadTool, |
| 12 | + toolActionLabel, |
| 13 | + toolActionPast, |
| 14 | + truncate, |
| 15 | +} from "./tool-labels.js"; |
8 | 16 | import { wrapText } from "./wrap.js"; |
9 | 17 |
|
10 | 18 | interface MessageProps { |
@@ -342,28 +350,6 @@ function CollapsedReadGroup({ |
342 | 350 | ); |
343 | 351 | } |
344 | 352 |
|
345 | | -function presentVerbForReadTool(name: string): string { |
346 | | - if (name === "read_file") return "Reading"; |
347 | | - if (name === "list_files") return "Listing"; |
348 | | - if (name === "glob") return "Searching"; |
349 | | - if (name === "grep") return "Grepping"; |
350 | | - return "Running"; |
351 | | -} |
352 | | - |
353 | | -function pastVerbForReadTool(name: string): string { |
354 | | - if (name === "read_file") return "Read"; |
355 | | - if (name === "list_files") return "Listed"; |
356 | | - if (name === "glob") return "Searched"; |
357 | | - if (name === "grep") return "Grepped"; |
358 | | - return "Ran"; |
359 | | -} |
360 | | - |
361 | | -function nounForReadTool(name: string, count: number): string { |
362 | | - if (name === "read_file") return count === 1 ? "file" : "files"; |
363 | | - if (name === "list_files") return count === 1 ? "directory" : "directories"; |
364 | | - return count === 1 ? "call" : "calls"; |
365 | | -} |
366 | | - |
367 | 353 | /** One word-level span inside a paired remove/add line. */ |
368 | 354 | interface WordPart { |
369 | 355 | text: string; |
@@ -707,206 +693,3 @@ function blockKey(block: { type: string; id?: string }, idx: number): string { |
707 | 693 | return `${block.type}-${idx}`; |
708 | 694 | } |
709 | 695 |
|
710 | | -function summarizeArgs(args: unknown): string { |
711 | | - if (!args || typeof args !== "object") return ""; |
712 | | - const entries = Object.entries(args as Record<string, unknown>).slice(0, 3); |
713 | | - return entries |
714 | | - .map(([k, v]) => { |
715 | | - const s = typeof v === "string" ? `"${v.slice(0, 30)}"` : String(v); |
716 | | - return `${k}=${s}`; |
717 | | - }) |
718 | | - .join(", "); |
719 | | -} |
720 | | - |
721 | | -/** |
722 | | - * Render a tool call as a human-friendly action label, the way Claude |
723 | | - * Code formats them: present-tense verb + the salient argument |
724 | | - * (file path, command, URL, search query, etc.) instead of the raw |
725 | | - * `toolName(k1=v1, k2=v2)` shape. Falls back to the verbose form for |
726 | | - * tools we don't have a special case for. |
727 | | - */ |
728 | | -function toolActionLabel(name: string, args: unknown): string { |
729 | | - const a = (args ?? {}) as Record<string, unknown>; |
730 | | - const str = (k: string): string => (typeof a[k] === "string" ? (a[k] as string) : ""); |
731 | | - const path = displayPath(str("path") || str("file_path")); |
732 | | - |
733 | | - switch (name) { |
734 | | - case "read_file": |
735 | | - return `Reading ${path}`; |
736 | | - case "write_file": |
737 | | - return `Writing ${path}`; |
738 | | - case "edit_file": |
739 | | - return `Editing ${path}`; |
740 | | - case "multi_edit": |
741 | | - return `Editing ${path}`; |
742 | | - case "notebook_edit": |
743 | | - return `Editing notebook ${path}`; |
744 | | - case "list_files": |
745 | | - return `Listing ${path || "."}`; |
746 | | - case "glob": |
747 | | - return `Searching ${str("pattern")}`; |
748 | | - case "grep": |
749 | | - return `Searching for "${str("pattern")}"`; |
750 | | - case "shell": |
751 | | - return `Running: ${truncate(str("command") || str("cmd"), 60)}`; |
752 | | - case "web_fetch": |
753 | | - return `Fetching ${str("url")}`; |
754 | | - case "web_search": |
755 | | - return `Searching: ${truncate(str("query"), 60)}`; |
756 | | - case "git_status": |
757 | | - return "git status"; |
758 | | - case "git_diff": |
759 | | - return `git diff${str("target") ? ` ${str("target")}` : ""}`; |
760 | | - case "git_log": |
761 | | - return "git log"; |
762 | | - case "git_commit": |
763 | | - return `git commit: ${truncate(str("message"), 50)}`; |
764 | | - case "git_branch": |
765 | | - return str("name") ? `git branch ${str("name")}` : "git branches"; |
766 | | - case "enter_worktree": |
767 | | - return `Entering worktree ${str("branch") || str("name")}`; |
768 | | - case "exit_worktree": |
769 | | - return "Leaving worktree"; |
770 | | - case "enter_plan_mode": |
771 | | - return "Entering plan mode"; |
772 | | - case "exit_plan_mode": |
773 | | - return "Exiting plan mode"; |
774 | | - case "dispatch_agent": |
775 | | - return `Dispatching subagent: ${truncate(str("task"), 60)}`; |
776 | | - case "ask_user": |
777 | | - return `Asking: ${truncate(str("question"), 60)}`; |
778 | | - case "create_task": |
779 | | - return `Task: ${truncate(str("subject"), 60)}`; |
780 | | - case "update_task": |
781 | | - return `Updating task ${str("taskId")}`; |
782 | | - case "list_tasks": |
783 | | - return "Listing tasks"; |
784 | | - case "get_task": |
785 | | - return `Reading task ${str("taskId")}`; |
786 | | - case "save_memory": |
787 | | - return `Saving memory: ${str("name") || str("type")}`; |
788 | | - case "read_memory": |
789 | | - return str("filename") ? `Reading memory ${str("filename")}` : "Reading MEMORY.md"; |
790 | | - case "config": |
791 | | - return str("path") ? `config(${str("path")})` : "Reading config"; |
792 | | - default: |
793 | | - return `${name}(${summarizeArgs(args)})`; |
794 | | - } |
795 | | -} |
796 | | - |
797 | | -function truncate(s: string, n: number): string { |
798 | | - if (s.length <= n) return s; |
799 | | - return `${s.slice(0, n - 1)}…`; |
800 | | -} |
801 | | - |
802 | | -/** |
803 | | - * Show a path relative to the working directory when it's inside (so |
804 | | - * "src/ui/Message.tsx" instead of "/home/half/.../src/ui/Message.tsx"), |
805 | | - * but keep it absolute when it points outside the project — that's |
806 | | - * useful information the user should see at full fidelity. Empty |
807 | | - * strings pass through unchanged. |
808 | | - */ |
809 | | -function displayPath(p: string): string { |
810 | | - if (!p) return p; |
811 | | - const visible = makeRelative(p); |
812 | | - return hyperlinkPath(visible, p); |
813 | | -} |
814 | | - |
815 | | -function makeRelative(p: string): string { |
816 | | - if (!p.startsWith(pathSep)) return p; // already relative |
817 | | - const cwd = process.cwd(); |
818 | | - const rel = relativePath(cwd, p); |
819 | | - if (!rel || rel.startsWith("..")) return p; // outside cwd — keep absolute |
820 | | - return rel; |
821 | | -} |
822 | | - |
823 | | -/** |
824 | | - * Wrap a visible path in an OSC 8 hyperlink so terminals that support |
825 | | - * it (Ghostty, iTerm2, Kitty, recent gnome-terminal) make file paths |
826 | | - * clickable — click opens the file in $EDITOR / the OS default. The |
827 | | - * escape is zero-width and well-handled by wrap-ansi for width calc. |
828 | | - * Terminals that don't recognise OSC 8 silently strip it, so the |
829 | | - * fallback is just "non-clickable plain text" — no visible breakage. |
830 | | - * Opt-out: NO_HYPERLINK=1 (FORCE_HYPERLINK is honoured the other way, |
831 | | - * matching the common npm `supports-hyperlinks` convention). |
832 | | - */ |
833 | | -function hyperlinkPath(visible: string, rawPath: string): string { |
834 | | - if (process.env.NO_HYPERLINK === "1") return visible; |
835 | | - const absolute = isAbsolute(rawPath) ? rawPath : resolveAbsolute(process.cwd(), rawPath); |
836 | | - const url = `file://${absolute.split(pathSep).map(encodeURIComponent).join("/")}`; |
837 | | - return `\x1b]8;;${url}\x1b\\${visible}\x1b]8;;\x1b\\`; |
838 | | -} |
839 | | - |
840 | | -/** |
841 | | - * Past-tense action label, used when a tool has finished. Same shape |
842 | | - * as `toolActionLabel` but with the verbs swapped to past tense: |
843 | | - * "Reading X" → "Read X", "Editing Y" → "Edited Y", etc. |
844 | | - */ |
845 | | -function toolActionPast(name: string, args: unknown): string { |
846 | | - const a = (args ?? {}) as Record<string, unknown>; |
847 | | - const str = (k: string): string => (typeof a[k] === "string" ? (a[k] as string) : ""); |
848 | | - const path = displayPath(str("path") || str("file_path")); |
849 | | - |
850 | | - switch (name) { |
851 | | - case "read_file": |
852 | | - return `Read ${path}`; |
853 | | - case "write_file": |
854 | | - return `Wrote ${path}`; |
855 | | - case "edit_file": |
856 | | - return `Edited ${path}`; |
857 | | - case "multi_edit": |
858 | | - return `Edited ${path}`; |
859 | | - case "notebook_edit": |
860 | | - return `Edited notebook ${path}`; |
861 | | - case "list_files": |
862 | | - return `Listed ${path || "."}`; |
863 | | - case "glob": |
864 | | - return `Searched ${str("pattern")}`; |
865 | | - case "grep": |
866 | | - return `Searched for "${str("pattern")}"`; |
867 | | - case "shell": |
868 | | - return `Ran: ${truncate(str("command") || str("cmd"), 60)}`; |
869 | | - case "web_fetch": |
870 | | - return `Fetched ${str("url")}`; |
871 | | - case "web_search": |
872 | | - return `Searched: ${truncate(str("query"), 60)}`; |
873 | | - case "git_status": |
874 | | - return "git status"; |
875 | | - case "git_diff": |
876 | | - return `git diff${str("target") ? ` ${str("target")}` : ""}`; |
877 | | - case "git_log": |
878 | | - return "git log"; |
879 | | - case "git_commit": |
880 | | - return `git commit: ${truncate(str("message"), 50)}`; |
881 | | - case "git_branch": |
882 | | - return str("name") ? `git branch ${str("name")}` : "git branches"; |
883 | | - case "enter_worktree": |
884 | | - return `Entered worktree ${str("branch") || str("name")}`; |
885 | | - case "exit_worktree": |
886 | | - return "Left worktree"; |
887 | | - case "enter_plan_mode": |
888 | | - return "Entered plan mode"; |
889 | | - case "exit_plan_mode": |
890 | | - return "Exited plan mode"; |
891 | | - case "dispatch_agent": |
892 | | - return `Subagent: ${truncate(str("task"), 60)}`; |
893 | | - case "ask_user": |
894 | | - return `Asked: ${truncate(str("question"), 60)}`; |
895 | | - case "create_task": |
896 | | - return `Created task: ${truncate(str("subject"), 60)}`; |
897 | | - case "update_task": |
898 | | - return `Updated task ${str("taskId")}`; |
899 | | - case "list_tasks": |
900 | | - return "Listed tasks"; |
901 | | - case "get_task": |
902 | | - return `Read task ${str("taskId")}`; |
903 | | - case "save_memory": |
904 | | - return `Saved memory: ${str("name") || str("type")}`; |
905 | | - case "read_memory": |
906 | | - return str("filename") ? `Read memory ${str("filename")}` : "Read MEMORY.md"; |
907 | | - case "config": |
908 | | - return str("path") ? `config(${str("path")})` : "Read config"; |
909 | | - default: |
910 | | - return `${name}(${summarizeArgs(args)})`; |
911 | | - } |
912 | | -} |
0 commit comments