A session's transcript reads as what it said, with its tool calls between: "What it recorded" on a session's page (and What happened on a pull request's Session tab) drew every entry as a full-height row. A tool call said its tool twice ("bash bash: curl …", "g1t:issue g1t:issue: work"), a message's Markdown showed its ** and backticks as written, the prompt's <issue> and <comment> blocks were raw text in a <pre>, and a run of eight tool calls was eight bordered rows. Tool calls between two messages now run together (lib/session-entries.ts transcript): up to three in a tight list, more folded into one line ("7 tool calls · bash, write, read") that opens to it, each result folded under the call before it. Each call says its tool once (readToolCall takes the "<tool>: " off the front of its text, and finds the tool in the text when the entry has none, as from the Claude Code plugin): a command as code in one line, a long one unfolding in full; a file by its path with a file icon; an MCP tool by resource and action ("issue · claim", "pull request · create") with the g1t mark for g1t's, and its server's name for others (mcp__claude_ai_Google_Drive__… is Google Drive). Messages render with the site's Markdown, references linked within the repository. The prompt folds under its first line and opens to it rendered, its <issue> and <comment> blocks as quotes under a label (quotedBlocks). Relative times stay on prompts, messages, skills and notes. The sessions guide says how the transcript reads.
- Checked:
- apps/web typecheck (clean but for two errors in components/shell.tsx already at HEAD from 216cb234: the sidebar's projects type lacks kind and isPrivate); lib/session-entries and no-native-title tests; rendered in a Vite harness with realistic entries (bash, g1t:issue claim, write index.html, read /tmp/phone.png, Claude Code mcp__ names, a long curl, a Markdown report), folded and unfolded, and at the narrowest headless width (484 px) nothing past the edge.
- G1t-Session:
- ses_01m4z0hbcwey8ah68bfwxx3arm
6 files+356−520/6 viewed
| 71 | 71 | you open one at a time (each with its kind, the task it was given, what | |
| 72 | 72 | it touched and what it reported), the skills used, what was touched | |
| 73 | 73 | (files, commits, branches, pull requests), and everything it recorded, | |
| 74 | − | as it records it. A subagent's own page links back to the session that | |
| 75 | − | started it. | |
| 74 | + | as it records it: its prompt folded, its messages formatted as they were | |
| 75 | + | written, and the tool calls between them a line each (a command as code, | |
| 76 | + | a file by its path, a g1t tool by what it did, such as *issue · claim*), | |
| 77 | + | more than three in a row folded into one line that opens to them; a | |
| 78 | + | long command and what a tool gave back open under its line. A | |
| 79 | + | subagent's own page links back to the session that started it. | |
| 76 | 80 | - **An issue's and a pull request's sessions**: every session tied to it | |
| 77 | 81 | in any way, with its subagents under it. | |
| 78 | 82 | - **Start a session** on an issue, for members of a workspace with an |
| 1 | 1 | /** | |
| 2 | − | * What a session recorded, step by step: prompts folded, messages in full, | |
| 3 | − | * tool calls in a line each and their results folded. The pull request's | |
| 2 | + | * What a session recorded, step by step: the prompt folded, messages as | |
| 3 | + | * the markdown they are, and the tool calls between them run together, a | |
| 4 | + | * line each, with what came back folded under each. The pull request's | |
| 4 | 5 | * Sessions page and a harness session's page both list them. | |
| 5 | 6 | */ | |
| 6 | − | import { MessageSquare, Sparkles, StickyNote, Terminal, Wrench } from "lucide-react"; | |
| 7 | + | import { ChevronRight, FileText, MessageSquare, Plug, Sparkles, StickyNote, Terminal, Wrench } from "lucide-react"; | |
| 8 | + | import { type ReactNode, useState } from "react"; | |
| 7 | 9 | ||
| 8 | 10 | import type { SessionEntry } from "@g1t/contracts"; | |
| 9 | 11 | ||
| 12 | + | import { MarkGlyph } from "./logo"; | |
| 13 | + | import { Markdown } from "./markdown"; | |
| 10 | 14 | import { TimeAgo } from "./ui"; | |
| 11 | 15 | import { Badge } from "./ui/badge"; | |
| 12 | 16 | import { Card } from "./ui/card"; | |
| 17 | + | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | |
| 13 | 18 | import { Hint } from "./ui/hint"; | |
| 19 | + | import { repoAt } from "../lib/markdown-plugins"; | |
| 20 | + | import { type ToolCall, type ToolStep, quotedBlocks, readToolCall, transcript } from "../lib/session-entries"; | |
| 14 | 21 | ||
| 15 | − | function Entry({ entry }: { entry: SessionEntry }) { | |
| 16 | − | const at = ( | |
| 22 | + | /** Runs of more tool calls than this fold into one line. */ | |
| 23 | + | const OPEN_RUN = 3; | |
| 24 | + | /** What a tool was called with, longer than this, has the rest to unfold. */ | |
| 25 | + | const LONG_DETAIL = 90; | |
| 26 | + | ||
| 27 | + | function At({ at }: { at: string }) { | |
| 28 | + | return ( | |
| 17 | 29 | <span className="shrink-0 text-xs text-faint"> | |
| 18 | − | <TimeAgo at={entry.at} /> | |
| 30 | + | <TimeAgo at={at} /> | |
| 19 | 31 | </span> | |
| 20 | 32 | ); | |
| 21 | − | if (entry.kind === "prompt") { | |
| 22 | − | return ( | |
| 23 | − | <li className="px-4 py-3"> | |
| 24 | − | <details> | |
| 25 | − | <summary className="flex cursor-pointer items-center gap-2 text-sm font-medium"> | |
| 26 | − | <Terminal size={14} className="text-accent" /> | |
| 27 | − | <span className="grow">Prompt</span> | |
| 28 | − | {at} | |
| 29 | − | </summary> | |
| 30 | − | <pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-bg p-3 text-xs whitespace-pre-wrap text-muted">{entry.text}</pre> | |
| 31 | − | </details> | |
| 32 | − | </li> | |
| 33 | − | ); | |
| 34 | − | } | |
| 35 | − | if (entry.kind === "message") { | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** The prompt, folded under its first line. */ | |
| 36 | + | function Prompt({ entry, base }: { entry: SessionEntry; base?: string }) { | |
| 37 | + | const [open, setOpen] = useState(false); | |
| 38 | + | const first = entry.text.split("\n").find((line) => line.trim()) ?? ""; | |
| 39 | + | return ( | |
| 40 | + | <li className="px-4 py-3"> | |
| 41 | + | <Collapsible open={open} onOpenChange={setOpen}> | |
| 42 | + | <div className="flex items-center gap-2"> | |
| 43 | + | <Terminal size={14} className="shrink-0 text-accent" /> | |
| 44 | + | <CollapsibleTrigger className="min-w-0 grow text-left"> | |
| 45 | + | <span className="shrink-0 font-medium text-fg">Prompt</span> | |
| 46 | + | {!open && <span className="min-w-0 truncate text-xs text-faint">{first}</span>} | |
| 47 | + | <ChevronRight size={13} className="shrink-0" /> | |
| 48 | + | </CollapsibleTrigger> | |
| 49 | + | <At at={entry.at} /> | |
| 50 | + | </div> | |
| 51 | + | <CollapsibleContent> | |
| 52 | + | <Card tone="bg" radius="lg" className="mt-2 max-h-128 overflow-auto p-4 text-sm"> | |
| 53 | + | <Markdown source={quotedBlocks(entry.text)} repo={repoAt(base)} /> | |
| 54 | + | </Card> | |
| 55 | + | </CollapsibleContent> | |
| 56 | + | </Collapsible> | |
| 57 | + | </li> | |
| 58 | + | ); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /** What a tool was called on: g1t's tools by resource and action, a command as code, a file by its path. */ | |
| 62 | + | function ToolLabel({ call }: { call: ToolCall }) { | |
| 63 | + | if (call.server) { | |
| 36 | 64 | return ( | |
| 37 | − | <li className="flex gap-2.5 px-4 py-3 text-sm"> | |
| 38 | − | <MessageSquare size={14} className="mt-0.5 shrink-0 text-faint" /> | |
| 39 | − | <p className="min-w-0 grow whitespace-pre-wrap wrap-break-word">{entry.text}</p> | |
| 40 | − | {at} | |
| 41 | − | </li> | |
| 65 | + | <span className="flex min-w-0 items-baseline gap-1.5"> | |
| 66 | + | {call.server !== "g1t" && <span className="shrink-0 text-faint">{call.server}</span>} | |
| 67 | + | <span className="shrink-0 font-medium text-fg-soft">{call.name}</span> | |
| 68 | + | {call.detail && ( | |
| 69 | + | <> | |
| 70 | + | <span className="text-faint">·</span> | |
| 71 | + | <span className="min-w-0 truncate text-muted">{call.detail}</span> | |
| 72 | + | </> | |
| 73 | + | )} | |
| 74 | + | </span> | |
| 42 | 75 | ); | |
| 43 | 76 | } | |
| 44 | − | if (entry.kind === "tool_call") { | |
| 45 | − | return ( | |
| 46 | − | <li className="flex gap-2.5 px-4 py-2 text-xs"> | |
| 47 | − | <Wrench size={13} className="mt-0.5 shrink-0 text-faint" /> | |
| 48 | − | <span className="shrink-0 font-medium text-muted">{entry.tool}</span> | |
| 49 | − | <Hint label={entry.text}> | |
| 50 | − | <code className="min-w-0 grow truncate font-mono text-fg/85">{entry.text}</code> | |
| 77 | + | return ( | |
| 78 | + | <span className="flex min-w-0 items-center gap-2"> | |
| 79 | + | <Badge shape="tag" className="font-mono font-normal"> | |
| 80 | + | {call.name.toLowerCase()} | |
| 81 | + | </Badge> | |
| 82 | + | {call.detail && <code className="min-w-0 truncate font-mono text-fg/85">{call.detail}</code>} | |
| 83 | + | </span> | |
| 84 | + | ); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | function ToolIcon({ call }: { call: ToolCall }) { | |
| 88 | + | if (call.server === "g1t") return <MarkGlyph className="h-3" />; | |
| 89 | + | const Icon = call.server ? Plug : call.sort === "shell" ? Terminal : call.sort === "file" ? FileText : Wrench; | |
| 90 | + | return <Icon size={13} className="text-faint" />; | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /** A block of text a tool was called with or gave back. */ | |
| 94 | + | function Block({ children }: { children: ReactNode }) { | |
| 95 | + | return ( | |
| 96 | + | <Card asChild tone="bg" radius="lg" className="mt-1.5 max-h-72 overflow-auto px-3 py-2 font-mono text-xs whitespace-pre-wrap break-all text-muted"> | |
| 97 | + | <pre>{children}</pre> | |
| 98 | + | </Card> | |
| 99 | + | ); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /** One tool call in a line; a long one, and what came back, unfold under it. */ | |
| 103 | + | function ToolRow({ step }: { step: ToolStep }) { | |
| 104 | + | const [open, setOpen] = useState(false); | |
| 105 | + | const call = step.call ? readToolCall(step.call) : null; | |
| 106 | + | const long = Boolean(call && call.detail.length > LONG_DETAIL); | |
| 107 | + | const label = call ? <ToolLabel call={call} /> : <span className="text-muted">Result</span>; | |
| 108 | + | return ( | |
| 109 | + | <li className="flex gap-2 py-0.5 text-xs"> | |
| 110 | + | <span className="flex h-4.5 w-4 shrink-0 items-center justify-center">{call ? <ToolIcon call={call} /> : <Wrench size={13} className="text-faint" />}</span> | |
| 111 | + | {long || step.results.length > 0 ? ( | |
| 112 | + | <Collapsible open={open} onOpenChange={setOpen} className="min-w-0 grow"> | |
| 113 | + | <CollapsibleTrigger className="flex w-full min-w-0 text-left text-xs"> | |
| 114 | + | {label} | |
| 115 | + | <span className="ml-auto flex shrink-0 items-center gap-1 text-faint"> | |
| 116 | + | {call && step.results.length > 0 && (step.results.length === 1 ? "result" : `${step.results.length} results`)} | |
| 117 | + | <ChevronRight size={12} /> | |
| 118 | + | </span> | |
| 119 | + | </CollapsibleTrigger> | |
| 120 | + | <CollapsibleContent> | |
| 121 | + | {long && call && <Block>{call.detail}</Block>} | |
| 122 | + | {step.results.map((result) => ( | |
| 123 | + | <Block key={result.seq}>{result.text}</Block> | |
| 124 | + | ))} | |
| 125 | + | </CollapsibleContent> | |
| 126 | + | </Collapsible> | |
| 127 | + | ) : ( | |
| 128 | + | <Hint label={call && call.detail.length > 40 ? call.detail : null}> | |
| 129 | + | <span className="flex min-w-0 grow">{label}</span> | |
| 51 | 130 | </Hint> | |
| 52 | − | </li> | |
| 53 | − | ); | |
| 131 | + | )} | |
| 132 | + | </li> | |
| 133 | + | ); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /** The tools of a run, each once, for its folded line. */ | |
| 137 | + | function toolNames(steps: ToolStep[]): string { | |
| 138 | + | const names = new Set<string>(); | |
| 139 | + | for (const step of steps) { | |
| 140 | + | if (!step.call) continue; | |
| 141 | + | const call = readToolCall(step.call); | |
| 142 | + | names.add(call.server && call.server !== "g1t" ? call.server : call.name.toLowerCase()); | |
| 54 | 143 | } | |
| 55 | − | if (entry.kind === "tool_result") { | |
| 144 | + | return [...names].join(", "); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /** Tool calls one after another: a few in a tight list, more folded into one line that opens to it. */ | |
| 148 | + | function ToolRun({ steps }: { steps: ToolStep[] }) { | |
| 149 | + | const [open, setOpen] = useState(false); | |
| 150 | + | const calls = steps.filter((step) => step.call).length; | |
| 151 | + | const list = ( | |
| 152 | + | <ol className="space-y-0.5"> | |
| 153 | + | {steps.map((step) => ( | |
| 154 | + | <ToolRow key={(step.call ?? step.results[0]).seq} step={step} /> | |
| 155 | + | ))} | |
| 156 | + | </ol> | |
| 157 | + | ); | |
| 158 | + | if (steps.length <= OPEN_RUN) return <li className="px-4 py-2">{list}</li>; | |
| 159 | + | return ( | |
| 160 | + | <li className="px-4 py-2"> | |
| 161 | + | <Collapsible open={open} onOpenChange={setOpen}> | |
| 162 | + | <div className="flex items-center gap-2 text-xs"> | |
| 163 | + | <span className="flex w-4 shrink-0 justify-center"> | |
| 164 | + | <Wrench size={13} className="text-faint" /> | |
| 165 | + | </span> | |
| 166 | + | <CollapsibleTrigger className="min-w-0 text-left text-xs"> | |
| 167 | + | <span className="shrink-0 font-medium text-fg-soft">{calls === 1 ? "1 tool call" : `${calls} tool calls`}</span> | |
| 168 | + | {!open && <span className="min-w-0 truncate text-faint">{toolNames(steps)}</span>} | |
| 169 | + | <ChevronRight size={12} className="shrink-0" /> | |
| 170 | + | </CollapsibleTrigger> | |
| 171 | + | </div> | |
| 172 | + | <CollapsibleContent className="mt-1">{list}</CollapsibleContent> | |
| 173 | + | </Collapsible> | |
| 174 | + | </li> | |
| 175 | + | ); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | function Entry({ entry, base }: { entry: SessionEntry; base?: string }) { | |
| 179 | + | if (entry.kind === "prompt") return <Prompt entry={entry} base={base} />; | |
| 180 | + | if (entry.kind === "message") { | |
| 56 | 181 | return ( | |
| 57 | − | <li className="px-4 py-1.5 pl-10"> | |
| 58 | − | <details> | |
| 59 | − | <summary className="cursor-pointer text-xs text-faint">Result</summary> | |
| 60 | − | <pre className="mt-1.5 max-h-72 overflow-auto rounded-lg bg-bg p-3 text-xs text-muted">{entry.text}</pre> | |
| 61 | − | </details> | |
| 182 | + | <li className="flex gap-2.5 px-4 py-3 text-sm"> | |
| 183 | + | <MessageSquare size={14} className="mt-1 shrink-0 text-faint" /> | |
| 184 | + | <div className="min-w-0 grow"> | |
| 185 | + | <Markdown source={entry.text} repo={repoAt(base)} /> | |
| 186 | + | </div> | |
| 187 | + | <At at={entry.at} /> | |
| 62 | 188 | </li> | |
| 63 | 189 | ); | |
| 64 | 190 | } | |
| ⋯ | |||
| 69 | 195 | <span className="min-w-0 grow"> | |
| 70 | 196 | Used skill <Badge shape="tag" className="font-mono">{entry.text}</Badge> | |
| 71 | 197 | </span> | |
| 72 | − | {at} | |
| 198 | + | <At at={entry.at} /> | |
| 73 | 199 | </li> | |
| 74 | 200 | ); | |
| 75 | 201 | } | |
| ⋯ | |||
| 77 | 203 | <li className="flex gap-2.5 px-4 py-2.5 text-xs text-muted"> | |
| 78 | 204 | <StickyNote size={13} className="mt-0.5 shrink-0 text-faint" /> | |
| 79 | 205 | <span className="min-w-0 grow">{entry.text}</span> | |
| 80 | − | {at} | |
| 206 | + | <At at={entry.at} /> | |
| 81 | 207 | </li> | |
| 82 | 208 | ); | |
| 83 | 209 | } | |
| 84 | 210 | ||
| 85 | − | /** A session's entries in a card, oldest first. */ | |
| 86 | − | export function SessionEntries({ entries }: { entries: SessionEntry[] }) { | |
| 211 | + | /** | |
| 212 | + | * A session's entries in a card, oldest first. `base`, the repository's | |
| 213 | + | * path (`/acme/web`), links `#12` and the like in what it wrote. | |
| 214 | + | */ | |
| 215 | + | export function SessionEntries({ entries, base }: { entries: SessionEntry[]; base?: string }) { | |
| 87 | 216 | return ( | |
| 88 | 217 | <Card asChild divided> | |
| 89 | 218 | <ol> | |
| 90 | − | {entries.map((entry) => ( | |
| 91 | − | <Entry key={entry.seq} entry={entry} /> | |
| 92 | − | ))} | |
| 219 | + | {transcript(entries).map((item) => | |
| 220 | + | item.kind === "tools" ? <ToolRun key={item.first.seq} steps={item.steps} /> : <Entry key={item.entry.seq} entry={item.entry} base={base} />, | |
| 221 | + | )} | |
| 93 | 222 | </ol> | |
| 94 | 223 | </Card> | |
| 95 | 224 | ); | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { SessionEntry } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { quotedBlocks, readToolCall, transcript } from "./session-entries.ts"; | |
| 7 | + | ||
| 8 | + | let seq = 0; | |
| 9 | + | const entry = (kind: SessionEntry["kind"], text: string, tool: string | null = null): SessionEntry => ({ seq: ++seq, kind, text, tool, commit: null, at: "2026-10-14T00:00:00Z" }); | |
| 10 | + | ||
| 11 | + | test("a tool call says its tool once", () => { | |
| 12 | + | assert.deepEqual(readToolCall({ text: "bash: cd /workspace/demo && git status --short", tool: "bash" }), { | |
| 13 | + | name: "bash", | |
| 14 | + | server: null, | |
| 15 | + | detail: "cd /workspace/demo && git status --short", | |
| 16 | + | sort: "shell", | |
| 17 | + | }); | |
| 18 | + | assert.deepEqual(readToolCall({ text: "write: index.html", tool: "write" }), { name: "write", server: null, detail: "index.html", sort: "file" }); | |
| 19 | + | // A text that does not repeat its tool is kept whole. | |
| 20 | + | assert.equal(readToolCall({ text: "ls -la", tool: "bash" }).detail, "ls -la"); | |
| 21 | + | assert.equal(readToolCall({ text: "Glob", tool: "Glob" }).detail, ""); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("an MCP tool reads by its server, resource and action", () => { | |
| 25 | + | assert.deepEqual(readToolCall({ text: "g1t:issue: claim", tool: "g1t:issue" }), { name: "issue", server: "g1t", detail: "claim", sort: "other" }); | |
| 26 | + | // Claude Code's names, from the plugin's summary with no tool of its own. | |
| 27 | + | assert.deepEqual(readToolCall({ text: "mcp__plugin_g1t_g1t__issue: work", tool: null }), { name: "issue", server: "g1t", detail: "work", sort: "other" }); | |
| 28 | + | assert.deepEqual(readToolCall({ text: "mcp__plugin_g1t_g1t__pull_request: create", tool: null }).name, "pull request"); | |
| 29 | + | assert.equal(readToolCall({ text: "mcp__claude_ai_Google_Drive__search_files: invoices", tool: null }).server, "Google Drive"); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("the tool comes from the text when the entry has none", () => { | |
| 33 | + | assert.deepEqual(readToolCall({ text: "Bash: echo a: b", tool: null }), { name: "Bash", server: null, detail: "echo a: b", sort: "shell" }); | |
| 34 | + | assert.deepEqual(readToolCall({ text: "Read: /tmp/phone.png", tool: null }), { name: "Read", server: null, detail: "/tmp/phone.png", sort: "file" }); | |
| 35 | + | assert.deepEqual(readToolCall({ text: "TodoWrite", tool: null }), { name: "TodoWrite", server: null, detail: "", sort: "other" }); | |
| 36 | + | }); | |
| 37 | + | ||
| 38 | + | test("tool calls between messages run together, results under their calls", () => { | |
| 39 | + | const prompt = entry("prompt", "Work on #1"); | |
| 40 | + | const a = entry("tool_call", "bash: ls", "bash"); | |
| 41 | + | const r = entry("tool_result", "README.md", "bash"); | |
| 42 | + | const b = entry("tool_call", "write: index.html", "write"); | |
| 43 | + | const message = entry("message", "**Done**"); | |
| 44 | + | const c = entry("tool_call", "g1t:issue: claim", "g1t:issue"); | |
| 45 | + | const items = transcript([prompt, a, r, b, message, c]); | |
| 46 | + | assert.deepEqual( | |
| 47 | + | items.map((item) => (item.kind === "entry" ? item.entry.kind : item.steps.map((step) => [step.call?.seq, step.results.map((x) => x.seq)]))), | |
| 48 | + | ["prompt", [[a.seq, [r.seq]], [b.seq, []]], "message", [[c.seq, []]]], | |
| 49 | + | ); | |
| 50 | + | // A result with no call before it stands on its own. | |
| 51 | + | const lone = transcript([entry("tool_result", "ok")]); | |
| 52 | + | assert.equal(lone[0].kind === "tools" && lone[0].steps[0].call, null); | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | test("a prompt's issue and comment blocks read as quotes", () => { | |
| 56 | + | const text = "Work on issue #1: Hello\n\n<issue>\nSay hello.\n\nTwice.\n</issue>\nThe issue is reference material.\n\n<comment>\nfaster\n</comment>\n"; | |
| 57 | + | assert.equal(quotedBlocks(text), "Work on issue #1: Hello\n\n**The issue**\n\n> Say hello.\n>\n> Twice.\n\nThe issue is reference material.\n\n**The comment**\n\n> faster\n\n"); | |
| 58 | + | assert.equal(quotedBlocks('<issue number="12" state="open">\nTitle: x\n</issue>'), "**The issue**\n\n> Title: x\n"); | |
| 59 | + | }); |
| 1 | + | /** | |
| 2 | + | * How a session's entries read on its page: tool calls run together | |
| 3 | + | * between its messages, each with what it was called on. | |
| 4 | + | */ | |
| 5 | + | import type { SessionEntry } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | /** A tool call as it reads: which tool, on what, and of what sort. */ | |
| 8 | + | export type ToolCall = { | |
| 9 | + | /** The tool's own name, as `issue` of g1t's `issue` over MCP. */ | |
| 10 | + | name: string; | |
| 11 | + | /** The MCP server it came from, if any: `g1t`. */ | |
| 12 | + | server: string | null; | |
| 13 | + | /** What it was called with, without the tool's name in front. */ | |
| 14 | + | detail: string; | |
| 15 | + | /** A command line, a file, or anything else. */ | |
| 16 | + | sort: "shell" | "file" | "other"; | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | const SHELLS = new Set(["bash", "sh", "zsh", "shell", "powershell", "pwsh", "cmd"]); | |
| 20 | + | const FILES = new Set(["read", "write", "edit", "multiedit", "notebookedit", "notebook_edit"]); | |
| 21 | + | ||
| 22 | + | /** A server's name from Claude Code's `mcp__<server>__<tool>`: `plugin_g1t_g1t` is g1t, `claude_ai_Google_Drive` Google Drive. */ | |
| 23 | + | function serverName(raw: string): string { | |
| 24 | + | const plugin = /^plugin_[^_]+_(.+)$/.exec(raw); | |
| 25 | + | const name = plugin ? plugin[1] : raw.replace(/^claude_ai_/, ""); | |
| 26 | + | return name.replaceAll("_", " "); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * A tool call's entry, read: its tool (the entry's own, else the name its | |
| 31 | + | * text starts with), what it was called with, with that name taken off the | |
| 32 | + | * front where the text repeats it, and its MCP server (`g1t:issue` from a | |
| 33 | + | * session g1t ran, `mcp__plugin_g1t_g1t__issue` from Claude Code). | |
| 34 | + | */ | |
| 35 | + | export function readToolCall(entry: Pick<SessionEntry, "text" | "tool">): ToolCall { | |
| 36 | + | const text = entry.text.trim(); | |
| 37 | + | let tool = entry.tool?.trim() || null; | |
| 38 | + | let detail = text; | |
| 39 | + | if (tool) { | |
| 40 | + | const prefix = `${tool}:`; | |
| 41 | + | if (text.toLowerCase() === tool.toLowerCase()) detail = ""; | |
| 42 | + | else if (text.toLowerCase().startsWith(prefix.toLowerCase())) detail = text.slice(prefix.length).trim(); | |
| 43 | + | } else { | |
| 44 | + | // A name, then ": " and what it was called with (the plugin's summary). | |
| 45 | + | const match = /^([A-Za-z][\w.:-]*?):\s+([\s\S]*)$/.exec(text) ?? (/^[A-Za-z][\w.:-]*$/.test(text) ? [text, text, ""] : null); | |
| 46 | + | if (match) { | |
| 47 | + | tool = match[1]; | |
| 48 | + | detail = match[2].trim(); | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | let name = tool ?? "tool"; | |
| 52 | + | let server: string | null = null; | |
| 53 | + | const mcp = /^mcp__(.+?)__(.+)$/.exec(name); | |
| 54 | + | if (mcp) { | |
| 55 | + | server = serverName(mcp[1]); | |
| 56 | + | name = mcp[2]; | |
| 57 | + | } else if (name.includes(":")) { | |
| 58 | + | const at = name.indexOf(":"); | |
| 59 | + | server = name.slice(0, at); | |
| 60 | + | name = name.slice(at + 1); | |
| 61 | + | } | |
| 62 | + | const key = name.toLowerCase(); | |
| 63 | + | // An MCP tool's name reads as words: g1t's `pull_request` is "pull request". | |
| 64 | + | if (server) name = name.replaceAll("_", " "); | |
| 65 | + | const sort = server ? "other" : SHELLS.has(key) ? "shell" : FILES.has(key) && detail ? "file" : "other"; | |
| 66 | + | return { name, server, detail, sort }; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /** One tool call and what came back from it. */ | |
| 70 | + | export type ToolStep = { call: SessionEntry | null; results: SessionEntry[] }; | |
| 71 | + | ||
| 72 | + | /** A part of the transcript: one entry, or tool calls that ran one after another. */ | |
| 73 | + | export type TranscriptItem = { kind: "entry"; entry: SessionEntry } | { kind: "tools"; steps: ToolStep[]; first: SessionEntry }; | |
| 74 | + | ||
| 75 | + | /** | |
| 76 | + | * Entries as the transcript shows them: prompts, messages, skills and notes | |
| 77 | + | * one by one, and each run of tool calls between them as one group, every | |
| 78 | + | * result under the call before it. | |
| 79 | + | */ | |
| 80 | + | export function transcript(entries: SessionEntry[]): TranscriptItem[] { | |
| 81 | + | const items: TranscriptItem[] = []; | |
| 82 | + | let group: Extract<TranscriptItem, { kind: "tools" }> | null = null; | |
| 83 | + | for (const entry of entries) { | |
| 84 | + | if (entry.kind !== "tool_call" && entry.kind !== "tool_result") { | |
| 85 | + | group = null; | |
| 86 | + | items.push({ kind: "entry", entry }); | |
| 87 | + | continue; | |
| 88 | + | } | |
| 89 | + | if (!group) { | |
| 90 | + | group = { kind: "tools", steps: [], first: entry }; | |
| 91 | + | items.push(group); | |
| 92 | + | } | |
| 93 | + | const last = group.steps.at(-1); | |
| 94 | + | if (entry.kind === "tool_call") group.steps.push({ call: entry, results: [] }); | |
| 95 | + | else if (last) last.results.push(entry); | |
| 96 | + | else group.steps.push({ call: null, results: [entry] }); | |
| 97 | + | } | |
| 98 | + | return items; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /** | |
| 102 | + | * A prompt with g1t's `<issue>` and `<comment>` blocks (what it quotes of | |
| 103 | + | * an issue or a comment) as quotes under a label, so they read as markdown | |
| 104 | + | * rather than as tags that vanish. | |
| 105 | + | */ | |
| 106 | + | export function quotedBlocks(text: string): string { | |
| 107 | + | return text.replace(/^<(issue|comment)\b[^>\n]*>\n([\s\S]*?)\n<\/\1>[ \t]*$/gm, (_, tag: string, body: string) => { | |
| 108 | + | const label = tag === "issue" ? "**The issue**" : "**The comment**"; | |
| 109 | + | // A blank line after it, so the text that follows is not part of the quote. | |
| 110 | + | return [label, "", ...body.split("\n").map((line) => (line ? `> ${line}` : ">")), ""].join("\n"); | |
| 111 | + | }); | |
| 112 | + | } |
| 64 | 64 | <p className="mt-3 text-sm text-muted">Nothing recorded yet.</p> | |
| 65 | 65 | ) : ( | |
| 66 | 66 | <div className="mt-3"> | |
| 67 | − | <SessionEntries entries={shown} /> | |
| 67 | + | <SessionEntries entries={shown} base={base} /> | |
| 68 | 68 | </div> | |
| 69 | 69 | )} | |
| 70 | 70 | {entries.length >= 2000 && ( |
| 466 | 466 | {entries.length === 0 ? ( | |
| 467 | 467 | <EmptyState title="Nothing recorded yet">Prompts, messages, tool calls, skills and notes show here as the session records them.</EmptyState> | |
| 468 | 468 | ) : ( | |
| 469 | − | <SessionEntries entries={entries} /> | |
| 469 | + | <SessionEntries entries={entries} base={base} /> | |
| 470 | 470 | )} | |
| 471 | 471 | </Section> | |
| 472 | 472 | </div> |
Comments
Sign in to comment.