{"version":3,"file":"tool-chain-summary.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/tool-chain-summary.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,iDAAiD;AACjD,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,2DAAyD;IACzD,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,uBAAuB;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,8CAA8C;IAC9C,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,UAAU;AACrB,6CAA6C;AAC3C,SAAS;AACX,yDAAyD;GACvD,MAAM;AACR,0EAA0E;GACxE,aAAa,CAAC;AAEjB,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,OAAO,GAAG,SAAS,CAAC;AAErD,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,WAAW,CAAC;CAClB;AAgFD,yEAAkE;AAClE,wBAAgB,aAAa,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,YAAY,EAAE,CAEnE;AAMD,kDAAkD;AAClD,wBAAgB,UAAU,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,KAAK,EAAE,UAAU,GAAG,MAAM,CAoB3E;AA2JD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,CAqCzD","sourcesContent":["/**\n * The radar view's chain line: one line for a run of consecutive tool calls.\n *\n * Two renderings of the same chain, because a run that is still going and a run\n * that is over answer different questions.\n *\n * While it runs you want the shape — what it did, in order, and where it broke:\n *\n *   ◐ grep › read › bash✗ › edit › bash…            4 done · 1 failed · running\n *\n * Once it is over the order has stopped mattering and what is left is what it\n * amounted to:\n *\n *   ● Edited packages/tui/src/keys.ts               5 calls · 1 failed\n *\n * A chain that did *not* finish keeps the running form plus a marker. The\n * settled form is a claim about what was accomplished, and a chain cut off\n * partway through has no such claim to make — the same reason\n * `settleDanglingMainTasks` refuses to mark an aborted turn's plan items done.\n *\n * Everything here is pure and deterministic. A model-written phase label would\n * read better than the inferred one, but it would cost tokens on every turn and\n * this agent's whole premise is that nothing happens you did not pay for on\n * purpose.\n */\n\n/** One tool call's contribution to its chain. */\nexport interface ChainEntry {\n\ttool: string;\n\t/** The call's subject — a path, a command, a pattern. */\n\tsubject: string;\n\tisError: boolean;\n\t/** Still executing. */\n\tisPartial: boolean;\n\t/** Lines of text output the call returned. */\n\toutputLines: number;\n}\n\nexport type ChainState =\n\t/** Calls are still arriving or executing. */\n\t| \"running\"\n\t/** Every call finished and the turn moved on cleanly. */\n\t| \"done\"\n\t/** The turn was aborted, errored, or hit a length cap partway through. */\n\t| \"interrupted\";\n\nexport type SegmentTone = \"ok\" | \"error\" | \"running\";\n\nexport interface ChainSegment {\n\tlabel: string;\n\ttone: SegmentTone;\n}\n\n/** Segments beyond this many are elided in the middle rather than wrapped. */\nconst MAX_SEGMENTS = 8;\n/** Kept at the head when eliding. */\nconst HEAD_SEGMENTS = 3;\n/** Kept at the tail when eliding. */\nconst TAIL_SEGMENTS = 2;\n\nfunction tone(entry: ChainEntry): SegmentTone {\n\tif (entry.isError) return \"error\";\n\tif (entry.isPartial) return \"running\";\n\treturn \"ok\";\n}\n\n/**\n * Collapse consecutive calls to the same tool into one segment.\n *\n * A run of five reads is one act, not five, and spelling it out crowds out the\n * calls either side of it. A failure never merges: `read ×4` hiding one broken\n * read would defeat the point of the line.\n */\nfunction collapseRepeats(entries: ChainEntry[]): ChainSegment[] {\n\tconst segments: ChainSegment[] = [];\n\tlet index = 0;\n\twhile (index < entries.length) {\n\t\tconst entry = entries[index];\n\t\tconst entryTone = tone(entry);\n\t\tlet run = 1;\n\t\twhile (\n\t\t\tindex + run < entries.length &&\n\t\t\tentries[index + run].tool === entry.tool &&\n\t\t\ttone(entries[index + run]) === entryTone &&\n\t\t\tentryTone === \"ok\"\n\t\t) {\n\t\t\trun++;\n\t\t}\n\t\tconst marker = entryTone === \"error\" ? \"✗\" : \"\";\n\t\tsegments.push({ label: run > 1 ? `${entry.tool} ×${run}` : `${entry.tool}${marker}`, tone: entryTone });\n\t\tindex += run;\n\t}\n\treturn segments;\n}\n\n/**\n * Elide the middle of a long chain, keeping both ends legible.\n *\n * Failures are never elided. Where a run broke is the one thing the line exists\n * to show, and a long chain is exactly when you most need it — hiding the ✗\n * inside \"… 22 more …\" would leave the stats claiming a failure the line cannot\n * point at.\n */\nfunction capSegments(segments: ChainSegment[]): ChainSegment[] {\n\tif (segments.length <= MAX_SEGMENTS) return segments;\n\n\tconst keep = new Set<number>();\n\tfor (let i = 0; i < HEAD_SEGMENTS; i++) keep.add(i);\n\tfor (let i = segments.length - TAIL_SEGMENTS; i < segments.length; i++) keep.add(i);\n\tsegments.forEach((segment, i) => {\n\t\tif (segment.tone === \"error\") keep.add(i);\n\t});\n\n\tconst out: ChainSegment[] = [];\n\tlet gap = 0;\n\tconst flushGap = () => {\n\t\tif (gap > 0) out.push({ label: `… ${gap} more …`, tone: \"ok\" });\n\t\tgap = 0;\n\t};\n\tsegments.forEach((segment, i) => {\n\t\tif (keep.has(i)) {\n\t\t\tflushGap();\n\t\t\tout.push(segment);\n\t\t} else {\n\t\t\tgap++;\n\t\t}\n\t});\n\tflushGap();\n\treturn out;\n}\n\n/** The `grep › read × 3 › bash✗` part of a running chain line. */\nexport function chainSegments(entries: ChainEntry[]): ChainSegment[] {\n\treturn capSegments(collapseRepeats(entries));\n}\n\nfunction plural(n: number, one: string, many = `${one}s`): string {\n\treturn `${n} ${n === 1 ? one : many}`;\n}\n\n/** The flush-right stats for either rendering. */\nexport function chainStats(entries: ChainEntry[], state: ChainState): string {\n\tconst failed = entries.filter((e) => e.isError).length;\n\tconst parts: string[] = [];\n\n\tif (state === \"running\") {\n\t\tparts.push(`${entries.filter((e) => !e.isPartial).length} done`);\n\t} else {\n\t\tparts.push(plural(entries.length, \"call\"));\n\t}\n\tif (failed > 0) parts.push(`${failed} failed`);\n\n\tif (state === \"running\") {\n\t\tparts.push(\"running\");\n\t} else if (state === \"interrupted\") {\n\t\tparts.push(\"interrupted\");\n\t} else {\n\t\tconst lines = entries.reduce((sum, e) => sum + e.outputLines, 0);\n\t\tif (lines > 0) parts.push(plural(lines, \"line\"));\n\t}\n\treturn parts.join(\" · \");\n}\n\ninterface ToolFamily {\n\tverb: string;\n\t/** Noun for the headline's count form: \"Edited 3 files\". */\n\tnoun: string;\n\t/** Noun for the secondary clause, which has no verb to lean on: \"· 38 reads\". */\n\ttail: string;\n\ttools: string[];\n\t/**\n\t * Whether this family's subjects name a place on disk.\n\t *\n\t * Only these may be reduced to a shared location. A command is not a path\n\t * however much it looks like one: three calls that all begin\n\t * `cd /Users/me/repo && …` share a long slash-separated prefix, and treating\n\t * it as a location produced `Ran cd /Users/me/repo` — a headline naming the\n\t * one part of the command that did nothing.\n\t */\n\tsubjectIsPath: boolean;\n\t/** Reduce a subject to the act it performed, when the raw form carries noise. */\n\tact?: (subject: string) => string;\n}\n\n/** Leading `cd <dir> &&` runs, however many are chained. */\nconst CD_PREFIX_RE = /^(?:cd\\s+(?:\"[^\"]*\"|'[^']*'|[^\\s;&|]+)\\s*&&\\s*)+/;\n\n/**\n * A command's act, with the navigation stripped off the front.\n *\n * `cd packages/tui && bun run check` is one act performed somewhere, not two,\n * and the somewhere is not what the chain did.\n */\nfunction commandAct(subject: string): string {\n\treturn subject.replace(CD_PREFIX_RE, \"\").trim() || subject;\n}\n\n/**\n * Tool families, in the order a settled chain reports them.\n *\n * The ordering is the whole trick: a chain that read six files and then changed\n * one is remembered as the edit. Mutation outranks execution outranks reading\n * outranks searching, so the phrase names the most consequential thing the\n * chain did rather than the most frequent.\n *\n * Priority alone is only sound while the chain is small enough for every call\n * to plausibly serve the headline act. See `INCIDENTAL_SHARE`.\n */\nconst FAMILIES: ToolFamily[] = [\n\t{\n\t\tverb: \"Edited\",\n\t\tnoun: \"file\",\n\t\ttail: \"edit\",\n\t\ttools: [\"edit\", \"write\", \"MultiEdit\", \"NotebookEdit\"],\n\t\tsubjectIsPath: true,\n\t},\n\t{ verb: \"Ran\", noun: \"command\", tail: \"command\", tools: [\"bash\"], subjectIsPath: false, act: commandAct },\n\t{ verb: \"Delegated\", noun: \"task\", tail: \"task\", tools: [\"Task\", \"TaskOutput\"], subjectIsPath: false },\n\t{ verb: \"Fetched\", noun: \"page\", tail: \"fetch\", tools: [\"webfetch\", \"websearch\"], subjectIsPath: false },\n\t{ verb: \"Read\", noun: \"file\", tail: \"read\", tools: [\"read\"], subjectIsPath: true },\n\t{ verb: \"Searched\", noun: \"search\", tail: \"search\", tools: [\"SearchCodebase\"], subjectIsPath: true },\n];\n\n/**\n * Calls from here up, a chain is long enough that priority order alone starts\n * lying, and the three rules below switch on.\n *\n * Below it the existing behaviour is left exactly as it was. Measured over this\n * repo's own sessions, 87% of chains are three calls or fewer and 75% touch a\n * single family; the failure these rules address does not exist down there, so\n * neither should the rules.\n */\nconst LONG_CHAIN_CALLS = 10;\n\n/**\n * A family this far below the chain's own size did not characterise it.\n *\n * Priority order says a chain that read six files and changed one is the edit,\n * and at seven calls that is true. At seventy-three it is not: 28 greps, 37\n * reads and one edit is an investigation that ended in a small change, and\n * calling it \"Edited subagent.ts\" describes 1 call out of 73.\n */\nconst INCIDENTAL_SHARE = 0.1;\n\n/** Below this, a secondary family is a footnote and not worth the width. */\nconst MIN_SECONDARY_CALLS = 3;\n\n/**\n * Longest common directory prefix of the given paths, or \"\" when they share none.\n *\n * `long` chains additionally reject a single-segment prefix: across forty files\n * the shared root collapses to something like `packages`, which names a location\n * so broad it is worse than the count it displaced. Short chains keep theirs —\n * `Edited docs` is a genuine location, and three files rarely share a vague one.\n */\nfunction commonPathPrefix(paths: string[], long: boolean): string {\n\tif (paths.length === 0) return \"\";\n\tconst split = paths.map((p) => p.split(\"/\").filter(Boolean));\n\tconst [first, ...rest] = split;\n\tconst prefix: string[] = [];\n\tfor (let i = 0; i < first.length; i++) {\n\t\tif (rest.every((parts) => parts[i] === first[i])) prefix.push(first[i]);\n\t\telse break;\n\t}\n\tif (prefix.length === 0) return \"\";\n\tif (long && prefix.length < 2) return \"\";\n\tconst joined = prefix.join(\"/\");\n\t// `filter(Boolean)` drops the empty leading segment of an absolute path.\n\treturn paths[0].startsWith(\"/\") ? `/${joined}` : joined;\n}\n\n/**\n * Whether a subject can stand in for a location.\n *\n * Globs are excluded even though they look pathlike: \"Searched *.test.ts\" reads\n * as a place the chain worked in, which is exactly what it is not.\n */\nfunction usableAsTarget(subject: string): boolean {\n\tif (/[*?]/.test(subject)) return false;\n\treturn subject.includes(\"/\") || /\\.[a-zA-Z0-9]+$/.test(subject);\n}\n\n/** The headline clause for one family's calls. */\nfunction familyPhrase(family: ToolFamily, matched: ChainEntry[], long: boolean): string {\n\tconst subjects = matched.map((e) => (family.act ? family.act(e.subject) : e.subject)).filter(Boolean);\n\n\t// A lone call is fully described by its own subject, whatever shape it is:\n\t// a command, a pattern, a URL, a path. So is a run that did the same thing\n\t// every time — `bun run check` twice is that command, not \"2 commands\".\n\tif (subjects.length === matched.length && new Set(subjects).size === 1) {\n\t\treturn `${family.verb} ${subjects[0]}`;\n\t}\n\n\t// Several calls: name the place they share, or fall back to a count.\n\tconst target = family.subjectIsPath ? commonPathPrefix(subjects.filter(usableAsTarget), long) : \"\";\n\tif (target) return `${family.verb} ${target}`;\n\t// Searching has no natural plural noun (\"3 searches\" says nothing the\n\t// stats do not), so the bare verb carries it.\n\tif (family.noun === \"search\") return \"Explored\";\n\treturn `${family.verb} ${plural(matched.length, family.noun)}`;\n}\n\n/** The family with the most calls other than `chosen`, if any reaches the floor. */\nfunction largestOtherFamily(\n\tentries: ChainEntry[],\n\tchosen: ToolFamily,\n): { family: ToolFamily; count: number } | undefined {\n\tlet best: { family: ToolFamily; count: number } | undefined;\n\tfor (const family of FAMILIES) {\n\t\tif (family === chosen) continue;\n\t\tconst count = entries.filter((e) => family.tools.includes(e.tool)).length;\n\t\tif (count > (best?.count ?? 0)) best = { family, count };\n\t}\n\treturn best && best.count >= MIN_SECONDARY_CALLS ? best : undefined;\n}\n\n/**\n * The settled chain's phrase: what this run amounted to.\n *\n * Deliberately close to one clause, not an inventory. \"Edited packages/tui, read\n * 6 files, searched packages\" is a worse line than \"Edited packages/tui\" — the\n * count is already in the stats, and the per-call detail is one stop down the\n * view dial.\n *\n * A long chain earns at most one more clause, and only when its headline family\n * is a minority of the calls. That is the case where a single clause stops being\n * terse and starts being false: 31 edits among 182 calls is worth naming, but so\n * is the fact that 76 of the rest were commands. Listing every family it touched\n * is not the alternative — a chain that reports \"(ran, delegated, read,\n * searched)\" has said only that it was busy.\n *\n * A location is only named when the calls actually share one. Naming a single\n * arbitrary file out of five would read as a claim the chain never made, so\n * several unrelated targets collapse to a count instead.\n */\nexport function chainPhrase(entries: ChainEntry[]): string {\n\tif (entries.length === 0) return \"No calls\";\n\tconst long = entries.length >= LONG_CHAIN_CALLS;\n\n\tlet chosen: ToolFamily | undefined;\n\tlet matched: ChainEntry[] = [];\n\tfor (const family of FAMILIES) {\n\t\tconst m = entries.filter((e) => family.tools.includes(e.tool));\n\t\tif (m.length === 0) continue;\n\t\tif (long && m.length / entries.length < INCIDENTAL_SHARE) continue;\n\t\tchosen = family;\n\t\tmatched = m;\n\t\tbreak;\n\t}\n\n\t// Every family was incidental — a long chain spread thin across many tools.\n\t// The largest still describes it better than the priority order's first hit.\n\tif (!chosen) {\n\t\tfor (const family of FAMILIES) {\n\t\t\tconst m = entries.filter((e) => family.tools.includes(e.tool));\n\t\t\tif (m.length > matched.length) {\n\t\t\t\tchosen = family;\n\t\t\t\tmatched = m;\n\t\t\t}\n\t\t}\n\t}\n\n\t// An unrecognised tool set still gets a line rather than a blank.\n\tif (!chosen) {\n\t\tconst names = [...new Set(entries.map((e) => e.tool))];\n\t\treturn names.length === 1 ? `Called ${names[0]}` : `Called ${plural(names.length, \"tool\")}`;\n\t}\n\n\tconst head = familyPhrase(chosen, matched, long);\n\tif (!long || matched.length / entries.length >= 0.5) return head;\n\tconst secondary = largestOtherFamily(entries, chosen);\n\treturn secondary ? `${head} · ${plural(secondary.count, secondary.family.tail)}` : head;\n}\n"]}