mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
* fix(native-chat): collapse a finished turn to its answer A finished turn's "Worked for N" row hid the turn's tool runs and nothing else. Every sentence the agent said on the way to its answer stayed in the transcript, so the resting state of a long chat was the narration, not the reply — one 16m 56s review turn left 21 assistant messages and roughly seven screens of scrolling behind a control that reads as if it had put the work away. The fold's unit is now the turn. A settled turn draws its prompt, its duration, and its answer; the narration and activity that produced it sit behind the caret. The answer is the turn's last assistant row that renders prose — derived, because the journal carries no marker saying which message is the reply. Collapsed stays derived rather than stored: nothing closes the disclosure when a turn ends, it arrives closed because the turn gained a duration. A running turn therefore folds nothing and the reader watches the work as it happens, which is what already happened and is now stated rather than inherited. Rows that outlive the turn that started them stay outside the fold — a spawn roster and a background task are often the only record of how that work ended. So does the reader's own message, question receipts, and the turn's diff rollup. A turn that produced no prose folds whole, its status row standing as the anchor. Two presentation changes come with it, both about the opened view: - A settled run's header was a call count followed by a monospace list of tool names and arguments. It is now one sentence in the transcript's own type — "Read 7 files, ran 17 commands, and searched 4 times" — built on the tool-category vocabulary that already picks the row's glyph, so the words and the icon cannot claim different things. A run of one command keeps that command as its header. - A tool call now owns its result instead of standing beside a separate `Result` row, so an opened run lists the work rather than twice as many rows half of which say `Result`. Output is one more click. Pairing is positional — a result answers the most recent unanswered call — because result blocks carry no call identifier to match on. Command previews also lose the `/bin/zsh -lc "…"` wrapper they all opened with. The unwrap happens inside `summarizeToolInput`, before truncation, because the clip at 80 characters removes the closing quote that proves the wrapper; one site fixes the header, the rows, and the running label. Measured on a real session journal at 1200x900: the turn above goes from 6,300px across 51 rows to 452px across 2, the whole session from 8,151px to 2,138px, and the same turn opened from 18,540px to 11,131px. The fold derivation lives in `src/shared` so the mobile transcript can read the same rule; wiring mobile's list to it is not part of this change. * fix(native-chat): preserve FIFO tool result pairing * fix(native-chat): keep tools collapsed when opening turn * test(native-chat): clarify independent tool disclosures
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import {
|
|
collapsedToolInputPrefix,
|
|
unwrapLoginShellCommand,
|
|
MAX_TOOL_PREVIEW_LENGTH
|
|
} from './native-chat-tool-preview-prefix'
|
|
import type { NativeChatMcpIdentity } from './native-chat-tool-identity'
|
|
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
|
|
|
|
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 {
|
|
// Unwrap before clipping: the closing quote is what proves the wrapper, and an
|
|
// 80-character prefix has already dropped it. Non-shell input is untouched.
|
|
const collapsed = collapsedToolInputPrefix(unwrapLoginShellCommand(toRawPreview(input)))
|
|
return collapsed.length <= MAX_TOOL_PREVIEW_LENGTH
|
|
? collapsed
|
|
: `${collapsed.slice(0, MAX_TOOL_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' && collapsedToolInputPrefix(input) !== 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_TOOL_PREVIEW_LENGTH) {
|
|
return collapsed
|
|
}
|
|
const tail = collapsed.slice(collapsed.length - (MAX_TOOL_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 {
|
|
let count = 0
|
|
blocks.forEach((block) => {
|
|
if (isToolCallBlock(block)) {
|
|
count += 1
|
|
}
|
|
})
|
|
return count
|
|
}
|
|
|
|
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
|
|
}
|