Skip to content

Commit 1034c41

Browse files
committed
refactor(ui): extract paths + tool-labels modules from Message.tsx
Two focused cuts from the 912-line god file the audit flagged as #1: - src/ui/paths.ts: displayPath, makeRelative, hyperlinkPath. Pure functions, no JSX, no React. Path display + OSC 8 hyperlink wrapping now lives where it can be unit-tested in isolation. - src/ui/tool-labels.ts: toolActionLabel, toolActionPast, presentVerbForReadTool, pastVerbForReadTool, nounForReadTool, plus the truncate + summarizeArgs helpers they share. The two switch statements (present and past tense per tool) were ~140 lines of Message.tsx; isolating them means adding a new tool's verb is a one-file change. Message.tsx is down from 912 to 695 lines. The remaining heavy components (DiffSummary, TruncatedOutput, WrappedLines, ToolCallLine) touch Ink rendering and deserve their own focused commit — easier to bisect if anything regresses than splitting them all at once.
1 parent ca7e419 commit 1034c41

3 files changed

Lines changed: 238 additions & 226 deletions

File tree

src/ui/Message.tsx

Lines changed: 9 additions & 226 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
import { isAbsolute, sep as pathSep, relative as relativePath, resolve as resolveAbsolute } from "node:path";
21
import type { AgentMessage } from "@earendil-works/pi-agent-core";
32
import { diffLines, diffWordsWithSpace } from "diff";
43
import { Box, Text } from "ink";
54
import { type ReactNode, useEffect, useState } from "react";
65
import type { ToolExecution } from "../types.js";
76
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";
816
import { wrapText } from "./wrap.js";
917

1018
interface MessageProps {
@@ -342,28 +350,6 @@ function CollapsedReadGroup({
342350
);
343351
}
344352

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-
367353
/** One word-level span inside a paired remove/add line. */
368354
interface WordPart {
369355
text: string;
@@ -707,206 +693,3 @@ function blockKey(block: { type: string; id?: string }, idx: number): string {
707693
return `${block.type}-${idx}`;
708694
}
709695

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-
}

src/ui/paths.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { isAbsolute, sep as pathSep, relative as relativePath, resolve as resolveAbsolute } from "node:path";
2+
3+
/**
4+
* Show a path relative to the working directory when it's inside (so
5+
* "src/ui/Message.tsx" instead of "/home/half/.../src/ui/Message.tsx"),
6+
* but keep it absolute when it points outside the project — that's
7+
* useful information the user should see at full fidelity. Empty
8+
* strings pass through unchanged. The returned string is wrapped in an
9+
* OSC 8 hyperlink so supporting terminals make it clickable.
10+
*/
11+
export function displayPath(p: string): string {
12+
if (!p) return p;
13+
const visible = makeRelative(p);
14+
return hyperlinkPath(visible, p);
15+
}
16+
17+
export function makeRelative(p: string): string {
18+
if (!p.startsWith(pathSep)) return p; // already relative
19+
const cwd = process.cwd();
20+
const rel = relativePath(cwd, p);
21+
if (!rel || rel.startsWith("..")) return p; // outside cwd — keep absolute
22+
return rel;
23+
}
24+
25+
/**
26+
* Wrap a visible path in an OSC 8 hyperlink so terminals that support
27+
* it (Ghostty, iTerm2, Kitty, recent gnome-terminal) make file paths
28+
* clickable — click opens the file in $EDITOR / the OS default. The
29+
* escape is zero-width and well-handled by wrap-ansi for width calc.
30+
* Terminals that don't recognise OSC 8 silently strip it, so the
31+
* fallback is just "non-clickable plain text" — no visible breakage.
32+
* Opt-out: NO_HYPERLINK=1.
33+
*/
34+
export function hyperlinkPath(visible: string, rawPath: string): string {
35+
if (process.env.NO_HYPERLINK === "1") return visible;
36+
const absolute = isAbsolute(rawPath) ? rawPath : resolveAbsolute(process.cwd(), rawPath);
37+
const url = `file://${absolute.split(pathSep).map(encodeURIComponent).join("/")}`;
38+
return `\x1b]8;;${url}\x1b\\${visible}\x1b]8;;\x1b\\`;
39+
}

0 commit comments

Comments
 (0)