mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* feat(native-chat): read a tool batch as a group A run of several tool calls collapsed to one joined string: names and arguments run together, separated by a middle dot that also occurs inside `browser.open` and `tools/read`, with the overflow cut mid-token. Opened, the member rows sat flush with the header and with the message content around them, so the batch had no visible end. Two presentation changes, no new derivation: - Each member gets its own bounded pill in the collapsed header, carrying its own category glyph, so the boundary between calls is a shape rather than a character. Pills wrap instead of truncating, and members past the summary cap are counted in `+N more` rather than dropped silently. - Opened members are indented under the header, which is what marks where the run ends. `toolRunSummaryMembers` keeps the run's leading calls apart instead of pre-joining them; `summarizeToolRun` now derives its string from it, so mobile's header is byte-identical and the two cannot disagree about which calls speak for a run. Two existing behaviours are pinned by test rather than changed, both being naming decisions rather than layout ones: the header still prints the raw `mcp__linear__list_issues` while the row beneath prints the split name, and a call carrying only a `url` still falls through to a JSON preview clipped at 28 characters. * fix(native-chat): bundle hidden tool count copy * fix(native-chat): drop the filled pill for a glyph-led member list Rendered in the app, the filled chips were wrong twice over. `bg-accent` is reserved for hover/active row backgrounds, and the only full-strength use of it in native chat is on payload and diff surfaces — so each member read as a shrunken content block, and a run became the loudest thing in the transcript. Worse, `flex-wrap` degenerated: at a 297px pane each member is 274-288px, so every one took its own line, the header grew 24px to 72px, and the `5x` count centred against the block landed beside the second member as though it counted that call alone. The glyph already marks where a member starts, so the fill was carrying no information the icon wasn't. Members are now inline, glyph-led, and separated by spacing; the list stays one line and truncates as a whole, as it did before this branch. `+N more` moves outside the truncating span so the count of what is not shown survives a pane too narrow to print the list. Members carry `data-tool-run-member` rather than being found by their fill. * fix(native-chat): let the run summary size to its content `flex-1` on the truncating member list made it claim the header's slack, so `+N more` was pushed to the far right edge with a gap between it and the last member it counts. Without it the span still shrinks and truncates — `min-w-0` plus the default shrink is what drives the ellipsis, which is how the header worked before this branch — and the count now sits directly after the list at every width. * fix(native-chat): separate run-header members with real whitespace An `ml-3` margin marks the boundary on screen but is invisible to a copied selection and to the button's accessible name, so the header read `ls -latools/read`. Adds a space text node between members and trims the margin to pay for its width. `+N more` also picks up the hover transition every other header segment already had. --------- Co-authored-by: Merge Sim <sim@local>
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import type { NativeChatMcpIdentity } from './native-chat-tool-identity'
|
|
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
|
|
|
|
const MAX_PREVIEW_LENGTH = 80
|
|
const MAX_PREVIEW_STRING_INPUT = 160
|
|
const MAX_PREVIEW_COLLECTION_ITEMS = 8
|
|
const MAX_PREVIEW_DEPTH = 2
|
|
const MAX_TOOL_RUN_SUMMARY_PARTS = 3
|
|
// Search term before command: a classified search row carries both, and the
|
|
// term is what identifies it. No other tool input supplies the two together.
|
|
// `directory` is a scan root or a listed folder — it labels a row but is
|
|
// deliberately absent from the file-target keys below, because a folder reaches
|
|
// mobile as a tappable open-file link that can only fail.
|
|
const PRIMARY_ARG_KEYS = [
|
|
'query',
|
|
'pattern',
|
|
'directory',
|
|
'command',
|
|
'cmd',
|
|
'url',
|
|
'description'
|
|
] as const
|
|
const BRIEF_ARG_KEYS = ['query', 'pattern', 'directory', 'command', 'cmd'] as const
|
|
// Only the keys that hold a shell command, so a search term or a listed folder
|
|
// cannot stand in for one.
|
|
const COMMAND_ARG_KEYS = ['command', 'cmd'] as const
|
|
export const MAX_TOOL_DETAIL_LENGTH = 4000
|
|
|
|
export type ToolInputDisplay = {
|
|
label: string
|
|
filePath: string | null
|
|
hasDetail: boolean
|
|
formatDetail: () => string
|
|
}
|
|
|
|
export function summarizeToolInput(input: unknown): string {
|
|
const collapsed = toRawPreview(input).replace(/\s+/g, ' ').trim()
|
|
return collapsed.length <= MAX_PREVIEW_LENGTH
|
|
? collapsed
|
|
: `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…`
|
|
}
|
|
|
|
/** Build the renderer-independent row model from one normalization pass. Detail
|
|
* formatting stays lazy because collapsed mobile rows never render it. */
|
|
export function createToolInputDisplay(input: unknown): ToolInputDisplay {
|
|
const normalized = normalizeToolInput(input)
|
|
const filePath = normalizedToolFilePath(normalized)
|
|
const label = describeNormalizedToolInput(normalized, filePath)
|
|
return {
|
|
label,
|
|
filePath,
|
|
hasDetail: normalizedToolInputHasDetail(normalized, label),
|
|
formatDetail: () => truncateToolDetail(formatNormalizedToolInput(normalized))
|
|
}
|
|
}
|
|
|
|
export function truncateToolDetail(text: string): string {
|
|
return text.length > MAX_TOOL_DETAIL_LENGTH ? `${text.slice(0, MAX_TOOL_DETAIL_LENGTH)}…` : text
|
|
}
|
|
|
|
/** Human label for a tool line: the target file path, else the primary string
|
|
* argument (command/query/…), else the bounded JSON preview. Keeps raw
|
|
* `{"file_path":…}` JSON out of the tappable row label. */
|
|
export function describeToolInput(input: unknown): string {
|
|
const normalized = normalizeToolInput(input)
|
|
return describeNormalizedToolInput(normalized, normalizedToolFilePath(normalized))
|
|
}
|
|
|
|
function describeNormalizedToolInput(input: unknown, path: string | null): string {
|
|
if (path) {
|
|
return summarizeToolPath(path)
|
|
}
|
|
if (input && typeof input === 'object') {
|
|
// Concrete target/action first; prose `description` only as a last resort.
|
|
const primary = firstPrimaryToolArg(input as Record<string, unknown>, PRIMARY_ARG_KEYS)
|
|
if (primary) {
|
|
return primary
|
|
}
|
|
}
|
|
return summarizeToolInput(input)
|
|
}
|
|
|
|
/** Full, pretty-printed tool-call input for the expanded detail view. Structured
|
|
* JSON strings and objects/arrays print as indented JSON so a diff-less call
|
|
* (e.g. a question payload) reads cleanly instead of one long minified line;
|
|
* other strings pass through as-is. */
|
|
export function formatToolInput(input: unknown): string {
|
|
return formatNormalizedToolInput(normalizeToolInput(input))
|
|
}
|
|
|
|
function formatNormalizedToolInput(input: unknown): string {
|
|
if (input === null || input === undefined) {
|
|
return ''
|
|
}
|
|
if (typeof input === 'string') {
|
|
return input
|
|
}
|
|
if (typeof input === 'number' || typeof input === 'boolean') {
|
|
return String(input)
|
|
}
|
|
try {
|
|
return JSON.stringify(input, null, 2) ?? ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/** Whether the expanded detail would show structured JSON rather than repeating
|
|
* the row label — i.e. whether expanding the row is worth offering. */
|
|
export function isStructuredToolInput(input: unknown): boolean {
|
|
return isStructuredNormalizedToolInput(normalizeToolInput(input))
|
|
}
|
|
|
|
function isStructuredNormalizedToolInput(input: unknown): boolean {
|
|
if (input === null || typeof input !== 'object') {
|
|
return false
|
|
}
|
|
// An empty object formats back to the row label verbatim, so offering the
|
|
// expander would promise detail and then repeat the row.
|
|
return Array.isArray(input) ? input.length > 0 : Object.keys(input).length > 0
|
|
}
|
|
|
|
function normalizedToolInputHasDetail(input: unknown, label: string): boolean {
|
|
if (isStructuredNormalizedToolInput(input)) {
|
|
return true
|
|
}
|
|
return typeof input === 'string' && input.replace(/\s+/g, ' ').trim() !== label
|
|
}
|
|
|
|
export function toolFilePath(input: unknown): string | null {
|
|
return normalizedToolFilePath(normalizeToolInput(input))
|
|
}
|
|
|
|
function normalizedToolFilePath(input: unknown): string | null {
|
|
if (!input || typeof input !== 'object') {
|
|
return null
|
|
}
|
|
const value = input as Record<string, unknown>
|
|
// A search call's `path` is usually the directory it scanned, so taking it as a
|
|
// target would label the row with the scan root and link to a folder. Costs the
|
|
// link on a file-scoped search; a dead link on every other search is worse.
|
|
const directory = isSearchToolInput(value) ? undefined : value.path
|
|
const path =
|
|
value.file_path ??
|
|
value.filePath ??
|
|
directory ??
|
|
value.notebook_path ??
|
|
firstPatchChangePath(value)
|
|
return typeof path === 'string' && path.length > 0 ? path : null
|
|
}
|
|
|
|
function firstPatchChangePath(value: Record<string, unknown>): unknown {
|
|
if (!Array.isArray(value.changes)) {
|
|
return undefined
|
|
}
|
|
for (const change of value.changes) {
|
|
if (typeof change === 'object' && change !== null && typeof change.path === 'string') {
|
|
return change.path
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export function briefToolArg(input: unknown): string {
|
|
const normalized = normalizeToolInput(input)
|
|
if (normalized && typeof normalized === 'object') {
|
|
const path = toolFilePath(normalized)
|
|
if (path) {
|
|
const parts = path.split(/[\\/]/).filter(Boolean)
|
|
return parts.at(-1) ?? path
|
|
}
|
|
const value = normalized as Record<string, unknown>
|
|
const command = firstPrimaryToolArg(value, BRIEF_ARG_KEYS)
|
|
if (command) {
|
|
return command.slice(0, 28)
|
|
}
|
|
// A blank primary key means the call has no brief argument; falling through
|
|
// would stand its raw JSON in for one in the run header. Reaching here with a
|
|
// string key means it was blank — a structured one still earns the preview.
|
|
if (BRIEF_ARG_KEYS.some((key) => typeof value[key] === 'string')) {
|
|
return ''
|
|
}
|
|
}
|
|
return summarizeToolInput(normalized).slice(0, 28)
|
|
}
|
|
|
|
/** The shell command a call carries in its input, or null when it carries none.
|
|
* Codex keeps the raw command on a classified `read`/`search`/`list` row, so
|
|
* this is what tells one apart from a Claude tool of the same lowercased word. */
|
|
export function toolInputCommand(input: unknown): string | null {
|
|
const normalized = normalizeToolInput(input)
|
|
return isToolInputRecord(normalized) ? firstPrimaryToolArg(normalized, COMMAND_ARG_KEYS) : null
|
|
}
|
|
|
|
function isToolInputRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
/** Codex delivers tool arguments as a JSON string. Parse those into the object
|
|
* shape every helper below already understands; leave prose strings alone. */
|
|
function normalizeToolInput(input: unknown): unknown {
|
|
if (typeof input !== 'string') {
|
|
return input
|
|
}
|
|
const first = input.trimStart()[0]
|
|
if (first !== '{' && first !== '[') {
|
|
return input
|
|
}
|
|
try {
|
|
const parsed: unknown = JSON.parse(input)
|
|
return parsed !== null && typeof parsed === 'object' ? parsed : input
|
|
} catch {
|
|
return input
|
|
}
|
|
}
|
|
|
|
/** A search call is named by what it looked for, so its `path` is a scan root
|
|
* rather than a file target. */
|
|
function isSearchToolInput(value: Record<string, unknown>): boolean {
|
|
return (
|
|
summarizePrimaryToolArg(value.query) !== null || summarizePrimaryToolArg(value.pattern) !== null
|
|
)
|
|
}
|
|
|
|
/** The first key that yields a label — a present-but-blank key must not swallow
|
|
* the keys ranked after it. */
|
|
function firstPrimaryToolArg(
|
|
value: Record<string, unknown>,
|
|
keys: readonly string[]
|
|
): string | null {
|
|
for (const key of keys) {
|
|
const summary = summarizePrimaryToolArg(value[key])
|
|
if (summary) {
|
|
return summary
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** A path is identified by its tail, so trim from the front: head-truncating an
|
|
* absolute path drops the filename, the one part that tells two rows apart. */
|
|
function summarizeToolPath(path: string): string {
|
|
const collapsed = path.replace(/\s+/g, ' ').trim()
|
|
if (collapsed.length <= MAX_PREVIEW_LENGTH) {
|
|
return collapsed
|
|
}
|
|
const tail = collapsed.slice(collapsed.length - (MAX_PREVIEW_LENGTH - 1))
|
|
// Start at a segment boundary so the label doesn't open mid-name.
|
|
const boundary = tail.search(/[\\/]/)
|
|
return `…${boundary > 0 ? tail.slice(boundary) : tail}`
|
|
}
|
|
|
|
/** A label-worthy primary argument: a non-blank string, or an argv array. */
|
|
function summarizePrimaryToolArg(input: unknown): string | null {
|
|
if (typeof input === 'string' && input.trim()) {
|
|
return summarizeToolInput(input)
|
|
}
|
|
if (Array.isArray(input) && input.length > 0 && input.every((part) => typeof part === 'string')) {
|
|
return summarizeToolInput(input.join(' '))
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** One named call in a run header, kept apart rather than pre-joined so a
|
|
* surface can draw the boundary between members itself. */
|
|
export type ToolRunMember = {
|
|
name: string
|
|
/** Brief argument, or '' when the call has none worth showing. */
|
|
arg: string
|
|
mcpIdentity?: NativeChatMcpIdentity
|
|
}
|
|
|
|
/** The run header's leading calls. Capped at the same limit the joined string
|
|
* has always used, so the two can never disagree about which calls speak for
|
|
* a run. */
|
|
export function toolRunSummaryMembers(blocks: readonly NativeChatBlock[]): ToolRunMember[] {
|
|
const members: ToolRunMember[] = []
|
|
for (const block of blocks) {
|
|
if (!isToolCallBlock(block)) {
|
|
continue
|
|
}
|
|
const name = block.name.trim()
|
|
if (!name) {
|
|
continue
|
|
}
|
|
members.push({ name, arg: briefToolArg(block.input), mcpIdentity: block.mcpIdentity })
|
|
if (members.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
|
|
break
|
|
}
|
|
}
|
|
return members
|
|
}
|
|
|
|
export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
|
|
return toolRunSummaryMembers(blocks)
|
|
.map((member) => (member.arg ? `${member.name} ${member.arg}` : member.name))
|
|
.join(' · ')
|
|
}
|
|
|
|
export function countToolCalls(blocks: readonly NativeChatBlock[]): number {
|
|
return blocks.filter(isToolCallBlock).length
|
|
}
|
|
|
|
function toRawPreview(input: unknown): string {
|
|
if (input === null || input === undefined) {
|
|
return ''
|
|
}
|
|
if (typeof input === 'string') {
|
|
return input
|
|
}
|
|
if (typeof input !== 'object') {
|
|
return String(input)
|
|
}
|
|
try {
|
|
return JSON.stringify(boundedPreviewValue(input, 0, new WeakSet<object>())) ?? ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function boundedPreviewValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {
|
|
if (typeof value === 'string') {
|
|
return value.length > MAX_PREVIEW_STRING_INPUT
|
|
? `${value.slice(0, MAX_PREVIEW_STRING_INPUT)}…`
|
|
: value
|
|
}
|
|
if (!value || typeof value !== 'object') {
|
|
return value
|
|
}
|
|
if (seen.has(value)) {
|
|
return '[circular]'
|
|
}
|
|
if (depth >= MAX_PREVIEW_DEPTH) {
|
|
return '[…]'
|
|
}
|
|
seen.add(value)
|
|
if (Array.isArray(value)) {
|
|
const result = value
|
|
.slice(0, MAX_PREVIEW_COLLECTION_ITEMS)
|
|
.map((item) => boundedPreviewValue(item, depth + 1, seen))
|
|
if (value.length > MAX_PREVIEW_COLLECTION_ITEMS) {
|
|
result.push('…')
|
|
}
|
|
return result
|
|
}
|
|
const result: Record<string, unknown> = {}
|
|
let count = 0
|
|
for (const key in value) {
|
|
if (!Object.hasOwn(value, key)) {
|
|
continue
|
|
}
|
|
if (count >= MAX_PREVIEW_COLLECTION_ITEMS) {
|
|
result['…'] = '…'
|
|
break
|
|
}
|
|
result[key] = boundedPreviewValue((value as Record<string, unknown>)[key], depth + 1, seen)
|
|
count += 1
|
|
}
|
|
return result
|
|
}
|