Selaa lähdekoodia

feat(trajectory): add a PTC code inspector

_Kerman 1 kuukausi sitten
vanhempi
sitoutus
632b8e19b4

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md
+2026-09-09-ptc-trajectory-code-inspection.md: 55cbbae97371b69b7574ebb0674c2178b586b83a
+2026-09-09-ptc-trajectory-code-inspection.zh.md: 94b6993a4e4e2d1f3e93256f79a68121f910169d

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md

@@ -0,0 +1,29 @@
+# Agent Note: Inspect recorded PTC source in Trajectory
+
+Status: implemented
+
+English | [中文](2026-09-09-ptc-trajectory-code-inspection.zh.md)
+
+## Problem
+
+PTC programs arrive as JSON string arguments. Escaping makes long programs difficult to read in a generic argument tree, while historical calls may use a different runtime language from the current deployment. Recorded result text can contain both printed output and a returned value without retaining their separation.
+
+## Decision
+
+[Trajectory](../../../../packages/client/ui-trajectory/README.md) derives a code inspector from recorded `run_code` arguments. It preserves source bytes for copying and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector.
+
+The result view preserves recorded text and uses a tree only for complete JSON objects or arrays. Errors retain captured output. Code views sample the shared wrapping preference when opened and keep their own choice while mounted.
+
+The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlement; the [client presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md) still owns deriving UI from recorded facts. This inspector adds no Session events or host presentation fields.
+
+## Alternatives considered
+
+**Keep source inside the argument tree.** JSON escaping obscures program structure and makes copying executable source cumbersome.
+
+**Use the active runtime language or infer it from source.** Either can mislabel a historical program. Recorded schema hints constrain highlighting without changing the recording.
+
+**Split printed output from return values.** Historical rendered text does not establish that distinction; a parser could invent a separation the producer never recorded.
+
+## Consequences
+
+Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Existing component tests cover fallback, exact copying, output states, and independent wrapping; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 在 Trajectory 中检查已记录的 PTC 源码
+
+Status: implemented
+
+[English](2026-09-09-ptc-trajectory-code-inspection.md) | 中文
+
+## 问题
+
+PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树中难以阅读,而历史调用使用的运行时语言可能不同于当前部署。记录的结果文本可以同时包含打印输出与返回值,却未保留二者的分界。
+
+## 决策
+
+[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 从已记录的 `run_code` 参数派生代码检查器。复制时保留源码字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。
+
+结果视图保留记录的文本,只对完整 JSON 对象或数组使用树形展示。错误保留已捕获的输出。代码视图在打开时读取共享换行偏好,并在挂载期间保留自身的选择。
+
+[PTC 运行时决策](2026-06-15-ptc.zh.md) 仍负责执行与结算;[客户端展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md) 仍负责从已记录事实派生 UI。此检查器不增加 Session 事件或宿主展示字段。
+
+## 考虑过的替代方案
+
+**把源码保留在参数树中。** JSON 转义遮蔽程序结构,也使复制可执行源码变得繁琐。
+
+**使用当前运行时语言,或从源码推断语言。** 两种方式都可能错误标注历史程序。已记录 Schema 的提示约束高亮,无需修改记录。
+
+**拆分打印输出与返回值。** 历史渲染文本无法确定二者的分界;解析器可能凭空添加生产方从未记录的分隔。
+
+## 后果
+
+读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。现有组件测试覆盖回退、原样复制、输出状态与独立换行;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。

+ 44 - 1
apps/web/tests/ptc-round.e2e.ts

@@ -8,13 +8,15 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
+const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url))
+const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 
 // Elicits the successful and failed sub-rows this scenario asserts.
@@ -151,6 +153,47 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   })
 
+  it.skipIf(MODE === 'record')('inspects recorded PTC source, wrapping, and original JSON in the trajectory', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-inspector'))
+    const call = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'run_code')
+    if (call?.type !== 'tool/call') throw new Error('recorded PTC call missing')
+    const args = JSON.parse(call.data.arguments) as { code: string; description: string }
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    const row = page.locator('tr[data-kind="tool"]').filter({ hasText: 'run_code' }).first()
+    await row.click()
+    await page.getByRole('tab', { name: 'Code', exact: true }).waitFor()
+    expect(await page.getByRole('tabpanel').textContent()).toContain(args.description)
+    expect(await page.getByRole('tabpanel').locator('dl').first().locator('dt').allTextContents())
+      .toEqual(['Hierarchy', 'Status'])
+    const overview = await Promise.all([1, 2].map(index => captureStableAria(
+      page, `[role="tabpanel"] [class*="overviewSections"] > section:nth-child(${index})`, scaffold.workspaceCwd,
+    )))
+    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, overview.join('\n'), MODE)
+
+    await page.getByRole('button', { name: 'Code', exact: true }).click()
+    const source = page.locator('[data-line-numbers] pre')
+    await source.waitFor()
+    expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code)
+    const wrap = page.getByRole('button', { name: 'Wrap lines', exact: true })
+    expect(await wrap.getAttribute('aria-pressed')).toBe('false')
+    const content = page.locator('[data-wrap]').filter({ has: source })
+    expect(await content.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true)
+    await wrap.click()
+    expect(await wrap.getAttribute('aria-pressed')).toBe('true')
+    await expect.poll(() => content.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1)
+    const code = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(CODE_EXPECTED, code, MODE)
+
+    await page.getByRole('button', { name: 'Original JSON' }).click()
+    const json = page.getByRole('tree', { name: 'parameters JSON' })
+    await json.waitFor()
+    expect(await json.textContent()).toContain(args.description)
+    await page.getByRole('button', { name: 'Original JSON' }).click()
+    expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code)
+    await page.getByRole('tab', { name: 'Summary', exact: true }).click()
+    expect(await wrap.getAttribute('aria-pressed')).toBe('true')
+  })
+
   it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73
-README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6
+README.md: 6b92450428e1b109db127e99de282e215a98eb8a
+README.zh.md: dc8e07af8021c65cca1694cb8a8e8e8579269619

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
-| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. |
+| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. |
 | `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. |

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -52,7 +52,7 @@ kind: "package-library"
 | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
-| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 |
+| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 |
 | `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 |

+ 7 - 3
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -27,6 +27,8 @@ export interface CodeBlockProps {
   className?: string | undefined
   /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
   lineNumbers?: boolean | undefined
+  /** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */
+  showHeader?: boolean | undefined
   /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
   copyLabel: string
   /** Copy-button label during the post-copy confirmation window. */
@@ -59,7 +61,9 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
   )
 }
 
-export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
+export function CodeBlock({
+  code, lang, streaming, className, lineNumbers = false, showHeader = true, copyLabel, copiedLabel,
+}: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const rootRef = useRef<HTMLDivElement>(null)
@@ -177,7 +181,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
       style={sourceLines === undefined ? undefined : {
         '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
       } as CSSProperties}>
-      <div className={css.bannerWrap}>
+      {showHeader && <div className={css.bannerWrap}>
         <div className={css.banner}>
           <div className={css.infostring}>{lang ?? ''}</div>
           <div className={css.action}>
@@ -186,7 +190,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
             </button>
           </div>
         </div>
-      </div>
+      </div>}
       {body}
     </div>
   )

+ 2 - 2
packages/client/ui-trajectory/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
-README.md: 099ce8c9330040e106ab4ab1bfbf155d32a38e79
-README.zh.md: 7b00cd8ccf8dfdc36f6df55e3b0e7c1ae8fe5847
+README.md: 80d8898e0fabd4671581a84198250fe408d51009
+README.zh.md: 5c85faaadb56be6dc3cc85753494c7fd88333c92

+ 2 - 0
packages/client/ui-trajectory/README.md

@@ -29,6 +29,8 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit
 
 ### Inspecting records
 
+For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints.
+
 Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
 
 ### The timing overview

+ 2 - 0
packages/client/ui-trajectory/README.zh.md

@@ -29,6 +29,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间
 
 ### 检查记录
 
+对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。
+
 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
 
 ### 时间概览

+ 139 - 0
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -1261,6 +1261,145 @@
   padding-bottom: 0;
 }
 
+.programDescription {
+  flex: none;
+  margin: 0;
+  padding: 8px 14px 2px;
+  color: var(--dsw-alias-label-primary);
+  font: var(--dsw-font-xs-13);
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
+.programIcon {
+  margin-right: 4px;
+  vertical-align: -2px;
+}
+
+.programSummary {
+  margin-left: 5px;
+  font-family: var(--dsw-font-family);
+}
+
+.detailBodyProgram,
+.programPanel {
+  display: flex;
+  min-height: 0;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.programPanel {
+  flex: 1;
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.programActions {
+  display: inline-flex;
+  flex: none;
+  height: 16px;
+  align-items: center;
+  gap: 4px;
+  margin-left: auto;
+  padding-right: 14px;
+}
+
+.programLanguage {
+  padding-right: 4px;
+  color: var(--dsw-alias-label-tertiary);
+  font: 11px/16px var(--ds-font-family-code);
+}
+
+.programAction {
+  display: inline-flex;
+  box-sizing: border-box;
+  width: 20px;
+  height: 16px;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  border: 0;
+  color: var(--dsw-alias-label-secondary);
+  background: transparent;
+  cursor: pointer;
+}
+
+.programAction:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.programAction[aria-pressed='true'] {
+  color: var(--dsw-alias-state-business-primary);
+}
+
+.programAction[data-state='failed'] {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.programAction:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: -1px;
+}
+
+.programContent {
+  min-width: 0;
+  min-height: 0;
+}
+
+.programPanel > .programContent {
+  flex: 1;
+  overflow: auto;
+  overscroll-behavior: contain;
+}
+
+.programSource {
+  margin: 0 !important;
+  border-radius: 0;
+  background: var(--dsw-alias-bg-layer-1);
+  --dsl-code-block-line-white-space: pre;
+}
+
+.programSource :where(pre) {
+  padding: 2px 14px 8px;
+  overflow: visible;
+  border-radius: 0;
+  background: var(--dsw-alias-bg-layer-1) !important;
+  font: 12px/16px var(--ds-font-family-code);
+  overflow-wrap: normal;
+  word-break: normal;
+  tab-size: 2;
+}
+
+.programSource pre code > :global(.line) {
+  box-sizing: border-box;
+  width: max-content;
+  min-width: 100%;
+}
+
+.programContent[data-wrap='true'] .programSource {
+  --dsl-code-block-line-white-space: pre-wrap;
+}
+
+.programContent[data-wrap='true'] .programSource pre code > :global(.line) {
+  width: auto;
+  overflow-wrap: anywhere;
+  word-break: break-all;
+}
+
+.programOutput {
+  margin: 0;
+  padding: 2px 14px 8px;
+  color: var(--dsw-alias-label-primary);
+  font: 12px/16px var(--ds-font-family-code);
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
+.programError::first-line {
+  color: var(--dsw-alias-state-error-primary);
+  font-weight: 600;
+}
+
 .overviewPreview .jsonPreview > :first-child,
 .overviewPreview .schemaTree > :first-child {
   padding-top: 2px;

+ 221 - 37
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -1,16 +1,21 @@
 /** Turn-aware trajectory event ledger with a local record inspector. */
 
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
 import { useVirtualizer } from '@tanstack/react-virtual'
 import {
+  CodeBlock,
+  IconCheckOutline16,
   IconChevronRightOutline14,
+  IconCodeOutline16,
+  IconCopyOutline16,
   IconSettingsOutline16,
   IconSparkle16,
   IconUserOutline16,
   JsonTree,
   MarkdownText,
   Tooltip,
+  writeClipboard,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
 import { structuredPatch } from 'diff'
@@ -30,6 +35,7 @@ import type { TrajectoryTurnModel } from './layout.ts'
 import { trajectoryPreviewText } from './trajectory-preview.ts'
 import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts'
 import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts'
+import { codeProgram, type CodeProgram } from './code-program.ts'
 import css from './TrajectoryTable.module.css'
 
 const BOTTOM_FOLLOW_THRESHOLD_PX = 2
@@ -991,8 +997,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
   }
   return [
     { id: 'overview', labelKey: 'tab.summary' },
-    ...(record.cell.inputDetail ? [{ id: 'input', labelKey: 'tab.payload' } as const] : []),
-    ...(record.cell.outputDetail ? [{ id: 'output', labelKey: 'tab.result' } as const] : []),
+    ...(record.cell.inputDetail ? [{
+      id: 'input', labelKey: codeProgram(record.cell) === undefined ? 'tab.payload' : 'code.source',
+    } as const] : []),
+    ...(record.cell.outputDetail || codeProgram(record.cell) !== undefined
+      ? [{ id: 'output', labelKey: 'tab.result' } as const] : []),
     { id: 'schema', labelKey: 'tab.schema' },
     { id: 'timing', labelKey: 'tab.timing' },
   ]
@@ -1000,6 +1009,8 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
 
 function recordDisplayText(cell: TrajectoryCellProps, t: TrajectoryTranslate): string {
   if (isToolCallOnly(cell, t)) return ''
+  const program = codeProgram(cell)
+  if (program !== undefined) return `${cell.text.split(' · ', 1)[0]} · ${program.description.replace(/\s+/g, ' ')}`
   if (cell.previewMarkdown !== undefined) {
     const preview = trajectoryPreviewText(cell.previewMarkdown)
     if (cell.text === '') return preview
@@ -1099,10 +1110,11 @@ function RecordListText({
   return (
     <>
       <span className={css.toolCallNameTypeface}>
+        {toolCallText.name === 'run_code' && <IconCodeOutline16 className={css.programIcon} size={12} />}
         {toolCallText.name || '—'}
       </span>
       {toolCallText.args !== undefined && (
-        <span className={css.toolCallPayload}>
+        <span className={toolCallText.name === 'run_code' ? css.programSummary : css.toolCallPayload}>
           {toolCallText.args}
         </span>
       )}
@@ -1801,13 +1813,159 @@ function parseJsonContainer(value: string): object | undefined {
   }
 }
 
+function InspectorCopyButton({ text, label, t }: {
+  text: string
+  label: string
+  t: TrajectoryTranslate
+}) {
+  const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle')
+  useEffect(() => {
+    if (state === 'idle') return
+    const timer = setTimeout(() => { setState('idle') }, 1_500)
+    return () => { clearTimeout(timer) }
+  }, [state])
+  const title = state === 'idle' ? label : t(state === 'copied' ? 'copied' : 'copy.failed')
+  return (
+    <button
+      type="button"
+      className={css.programAction}
+      data-state={state}
+      aria-label={title}
+      title={title}
+      onClick={() => { void writeClipboard(text).then((ok) => { setState(ok ? 'copied' : 'failed') }) }}
+    >
+      {state === 'copied' ? <IconCheckOutline16 size={12} /> : <IconCopyOutline16 size={12} />}
+    </button>
+  )
+}
+
+function ProgramInput({ program, rawInput, initialWrapped, stringWrapping, onOpen, t }: {
+  program: CodeProgram
+  rawInput: string
+  initialWrapped: boolean
+  stringWrapping: JsonTreeProps['stringWrapping']
+  onOpen?: () => void
+  t: TrajectoryTranslate
+}) {
+  const contentsId = useId()
+  const [wrapped, setWrapped] = useState(initialWrapped)
+  const [showJson, setShowJson] = useState(false)
+  const actions = (
+    <span className={css.programActions}>
+      {!showJson && program.language !== undefined && (
+        <span className={css.programLanguage}>{program.language}</span>
+      )}
+      {!showJson && (
+        <button
+          type="button"
+          className={css.programAction}
+          aria-label={t('record.wrapLines')}
+          title={t('record.wrapLines')}
+          aria-pressed={wrapped}
+          aria-controls={contentsId}
+          onClick={() => {
+            const next = !wrapped
+            setWrapped(next)
+            stringWrapping?.setDefault(next)
+          }}
+        >
+          <svg width="12" height="12" viewBox="0 0 16 16" fill="none"
+            stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden="true">
+            <path d="M1.5 3.5h13M1.5 7.5h10.25a2.5 2.5 0 0 1 0 5H8m2-2-2 2 2 2M1.5 11.5h3" />
+          </svg>
+        </button>
+      )}
+      {onOpen === undefined && (
+        <button
+          type="button"
+          className={css.programAction}
+          aria-label={t('code.originalJson')}
+          title={t('code.originalJson')}
+          aria-pressed={showJson}
+          aria-controls={contentsId}
+          onClick={() => { setShowJson(value => !value) }}
+        >
+          <svg width="12" height="12" viewBox="0 0 16 16" fill="none"
+            stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+            <path d="M6 2H5a2 2 0 0 0-2 2v2a2 2 0 0 1-2 2 2 2 0 0 1 2 2v2a2 2 0 0 0 2 2h1" />
+            <path d="M10 2h1a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2 2 2 0 0 0-2 2v2a2 2 0 0 1-2 2h-1" />
+          </svg>
+        </button>
+      )}
+      <InspectorCopyButton
+        text={showJson ? rawInput : program.source}
+        label={t(showJson ? 'copy.json' : 'code.copySource')}
+        t={t}
+      />
+    </span>
+  )
+  const body = (
+    <div id={contentsId} className={css.programContent} data-wrap={wrapped}>
+      {showJson
+        ? <JsonTree data={program.arguments} label={t('record.parametersJson')}
+          labels={jsonTreeLabels(t)} stringWrapping={stringWrapping} />
+        : <CodeBlock code={program.source} lang={program.language} lineNumbers showHeader={false}
+          className={css.programSource} copyLabel={t('code.copySource')} copiedLabel={t('copied')} />}
+    </div>
+  )
+  return onOpen === undefined
+    ? (
+      <section className={css.programPanel}>
+        <header className={css.overviewHeading}>
+          <span>{t('code.source')}</span>
+          {actions}
+        </header>
+        {body}
+      </section>
+    )
+    : <OverviewSection label={t('code.source')} onOpen={onOpen} actions={actions}>{body}</OverviewSection>
+}
+
+function ProgramOutput({ record, stringWrapping, onOpen, t }: {
+  record: TableRecord
+  stringWrapping: JsonTreeProps['stringWrapping']
+  onOpen?: () => void
+  t: TrajectoryTranslate
+}) {
+  const output = record.cell.outputDetail
+  const json = output === undefined || record.cell.isError === true ? undefined : parseJsonContainer(output)
+  const actions = output === undefined ? undefined : (
+    <span className={css.programActions}>
+      <InspectorCopyButton text={output} label={t('code.copyOutput')} t={t} />
+    </span>
+  )
+  const body = (
+    <div className={css.programContent}>
+      {output === undefined || output === ''
+        ? <p className={css.noPayload}>{t(stateOf(record) === 'running' ? 'code.running' : 'record.noOutput')}</p>
+        : json !== undefined
+          ? <JsonTree data={json} label={t('record.outputJson')} labels={jsonTreeLabels(t)}
+            stringWrapping={stringWrapping} collapsedStringLines={onOpen === undefined ? 12 : 3} />
+          : <pre className={`${css.programOutput} ${record.cell.isError === true ? css.programError : ''}`}>{output}</pre>}
+    </div>
+  )
+  return onOpen === undefined
+    ? (
+      <section className={css.programPanel}>
+        <header className={css.overviewHeading}>
+          <span>{t('code.output')}</span>
+          {actions}
+        </header>
+        {body}
+      </section>
+    )
+    : <OverviewSection label={t('code.output')} onOpen={onOpen} actions={actions}>{body}</OverviewSection>
+}
+
 function OverviewSection({
   label,
   onOpen,
+  actions,
   children,
 }: {
   label: string
   onOpen: () => void
+  actions?: ReactNode
   children: ReactNode
 }) {
   const previewRef = useRef<HTMLDivElement>(null)
@@ -1838,6 +1996,7 @@ function OverviewSection({
           <span>{label}</span>
           <IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
         </button>
+        {actions}
       </h3>
       <div
         ref={previewRef}
@@ -1887,6 +2046,7 @@ export function TrajectoryTable({
   const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null)
   const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
   const [activeTab, setActiveTab] = useState<DetailTab>('overview')
+  const [codeWrappingOnOpen, setCodeWrappingOnOpen] = useState(false)
   const [thinkingDisclosure, setThinkingDisclosure] = useState<{ recordId: string; expanded: boolean }>()
   const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
   const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
@@ -1919,6 +2079,7 @@ export function TrajectoryTable({
   const selected = selectedTemplate === undefined
     ? undefined
     : currentRecord(selectedTemplate)
+  const selectedProgram = selected === undefined ? undefined : codeProgram(selected.cell)
   const thinkingExpanded = thinkingDisclosure?.recordId === selectedRecordId
     ? thinkingDisclosure.expanded
     : selected?.cell.kind === 'message'
@@ -2130,6 +2291,7 @@ export function TrajectoryTable({
     }
 
   const activateTab = (tab: DetailTab) => {
+    setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false)
     tabHistory.current.delete(tab)
     tabHistory.current.add(tab)
     setActiveTab(tab)
@@ -2146,6 +2308,7 @@ export function TrajectoryTable({
   }
 
   const selectRecord = useCallback((index: number) => {
+    setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false)
     const record = allRecords.find(candidate => candidate.cell.index === index)
     onRecordSelect?.(index)
     setSelectedRequest(null)
@@ -2155,7 +2318,7 @@ export function TrajectoryTable({
     const available = new Set(tabs.map(tab => tab.id))
     const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
     setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
-  }, [allRecords, onRecordSelect])
+  }, [allRecords, onRecordSelect, stringWrapping])
   useEffect(() => {
     if (
       recordSelection === null
@@ -2845,7 +3008,7 @@ export function TrajectoryTable({
                       <span className={css.detailsLocation}>
                         {selected.cell.kind === 'compacted'
                           ? sectionLabel(selected.turn, t)
-                          : `${sectionLabel(selected.turn, t)} · ${selected.group}`}
+                          : selected.group}
                       </span>
                     </>
                   )}
@@ -2879,7 +3042,9 @@ export function TrajectoryTable({
             id="trajectory-detail-panel"
             className={activeTab === 'overview'
               ? `${css.detailBody} ${css.detailBodySummary}`
-              : css.detailBody}
+              : selectedProgram !== undefined && (activeTab === 'input' || activeTab === 'output')
+                ? `${css.detailBody} ${css.detailBodyProgram}`
+                : css.detailBody}
             role="tabpanel"
             aria-labelledby={`trajectory-detail-${activeTab}`}
           >
@@ -3091,6 +3256,9 @@ export function TrajectoryTable({
               && selectedState !== undefined
               && activeTab === 'overview' && (
               <>
+                {selectedProgram !== undefined && selectedProgram.description !== '' && (
+                  <p className={css.programDescription}>{selectedProgram.description}</p>
+                )}
                 <dl
                   className={`${css.overview} ${css.summaryScrollRegion}`}
                   data-summary-scroll-region=""
@@ -3182,40 +3350,50 @@ export function TrajectoryTable({
                   )}
                 </dl>
                 <div className={css.overviewSections}>
-                  {isMarkdownRecord(selected)
+                  {selectedProgram !== undefined
                     ? (
                       <>
-                        <OverviewSection label={t('tab.preview')} onOpen={() => { activateTab('rendered') }}>
-                          <MarkdownRecordContent
-                            record={selected}
-                            renderImages={renderImages}
-                            rendered
-                            preview
-                            thinkingExpanded={thinkingExpanded}
-                            onThinkingExpandedChange={setThinkingExpanded}
-                            onOpenCall={openCallSummary}
-                            t={t}
-                          />
-                        </OverviewSection>
+                        <ProgramInput key={`preview:${selectedRecordId}`} program={selectedProgram}
+                          rawInput={selected.cell.inputDetail as string} initialWrapped={codeWrappingOnOpen}
+                          stringWrapping={stringWrapping} onOpen={() => { activateTab('input') }} t={t} />
+                        <ProgramOutput record={selected} stringWrapping={stringWrapping}
+                          onOpen={() => { activateTab('output') }} t={t} />
                       </>
                     )
-                    : (
-                      <>
-                        {selected.cell.inputDetail && (
-                          <OverviewSection label={t('tab.payload')} onOpen={() => { activateTab('input') }}>
-                            <RecordPayload record={selected} direction="input" preview renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                    : isMarkdownRecord(selected)
+                      ? (
+                        <>
+                          <OverviewSection label={t('tab.preview')} onOpen={() => { activateTab('rendered') }}>
+                            <MarkdownRecordContent
+                              record={selected}
+                              renderImages={renderImages}
+                              rendered
+                              preview
+                              thinkingExpanded={thinkingExpanded}
+                              onThinkingExpandedChange={setThinkingExpanded}
+                              onOpenCall={openCallSummary}
+                              t={t}
+                            />
                           </OverviewSection>
-                        )}
-                        {selected.cell.outputDetail && (
-                          <OverviewSection label={t('tab.result')} onOpen={() => { activateTab('output') }}>
-                            <RecordPayload record={selected} direction="output" preview renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                        </>
+                      )
+                      : (
+                        <>
+                          {selected.cell.inputDetail && (
+                            <OverviewSection label={t('tab.payload')} onOpen={() => { activateTab('input') }}>
+                              <RecordPayload record={selected} direction="input" preview renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                            </OverviewSection>
+                          )}
+                          {selected.cell.outputDetail && (
+                            <OverviewSection label={t('tab.result')} onOpen={() => { activateTab('output') }}>
+                              <RecordPayload record={selected} direction="output" preview renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                            </OverviewSection>
+                          )}
+                          <OverviewSection label={t('tab.schema')} onOpen={() => { activateTab('schema') }}>
+                            <RecordSchema record={selected} preview stringWrapping={stringWrapping} t={t} />
                           </OverviewSection>
-                        )}
-                        <OverviewSection label={t('tab.schema')} onOpen={() => { activateTab('schema') }}>
-                          <RecordSchema record={selected} preview stringWrapping={stringWrapping} t={t} />
-                        </OverviewSection>
-                      </>
-                    )}
+                        </>
+                      )}
                   {selectedAssistantRequestTarget !== undefined && (
                     <OverviewSection
                       label={t('timing.request')}
@@ -3260,10 +3438,16 @@ export function TrajectoryTable({
               <MessageSource record={selected} stringWrapping={stringWrapping} t={t} />
             )}
             {!promptSelected && selected !== undefined && activeTab === 'input' && (
-              <RecordPayload record={selected} direction="input" renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+              selectedProgram === undefined
+                ? <RecordPayload record={selected} direction="input" renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                : <ProgramInput key={`input:${selectedRecordId}`} program={selectedProgram}
+                  rawInput={selected.cell.inputDetail as string} initialWrapped={codeWrappingOnOpen}
+                  stringWrapping={stringWrapping} t={t} />
             )}
             {!promptSelected && selected !== undefined && activeTab === 'output' && (
-              <RecordPayload record={selected} direction="output" renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+              selectedProgram === undefined
+                ? <RecordPayload record={selected} direction="output" renderImages={renderImages} stringWrapping={stringWrapping} t={t} />
+                : <ProgramOutput record={selected} stringWrapping={stringWrapping} t={t} />
             )}
             {!promptSelected && selected !== undefined && activeTab === 'schema' && (
               <RecordSchema record={selected} stringWrapping={stringWrapping} t={t} />

+ 57 - 0
packages/client/ui-trajectory/src/client/code-program.ts

@@ -0,0 +1,57 @@
+/** Read replayable PTC source and language hints from recorded tool arguments and schemas. */
+import type { TrajectoryCellProps } from './trajectory-record.ts'
+
+/** Validated source and original arguments for one recorded run_code call. */
+export interface CodeProgram {
+  source: string
+  description: string
+  arguments: Record<string, unknown>
+  language: 'typescript' | 'python' | undefined
+}
+
+function record(value: unknown): Record<string, unknown> | undefined {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : undefined
+}
+
+function parseRecord(value: string | undefined): Record<string, unknown> | undefined {
+  if (value === undefined) return undefined
+  try {
+    return record(JSON.parse(value))
+  } catch {
+    return undefined
+  }
+}
+
+function recordedLanguage(schemaRaw: string | undefined): CodeProgram['language'] {
+  const schema = parseRecord(schemaRaw)
+  const properties = record(record(schema?.parameters)?.properties)
+  const description = record(properties?.code)?.description
+  if (typeof description !== 'string') return undefined
+  const typescript = /\bTypeScript\b/i.test(description)
+  const python = /\bPython\b/i.test(description)
+  if (typescript === python) return undefined
+  return typescript ? 'typescript' : 'python'
+}
+
+/**
+ * Resolve a PTC program without guessing its language from source or current runtime settings.
+ * @param cell - Recorded tool arguments and the schema visible at call time.
+ * @returns The program, or undefined for another tool or unsupported arguments.
+ */
+export function codeProgram(cell: TrajectoryCellProps): CodeProgram | undefined {
+  if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined
+  if (cell.text.split(' · ', 1)[0] !== 'run_code') return undefined
+  const args = parseRecord(cell.inputDetail)
+  if (typeof args?.code !== 'string') return undefined
+  if (args.description !== undefined && typeof args.description !== 'string') return undefined
+  return {
+    source: args.code,
+    description: typeof args.description === 'string' && args.description.trim() !== ''
+      ? args.description
+      : args.code.split(/\r?\n/).find(line => line.trim() !== '')?.trim() ?? '',
+    arguments: args,
+    language: recordedLanguage(cell.schemaDetail),
+  }
+}

+ 12 - 0
packages/client/ui-trajectory/src/client/locales.ts

@@ -115,6 +115,12 @@ export const zh = {
   'record.outputJson': '结果 JSON',
   'record.thinking': '思考',
   'record.wrapLines': '自动换行',
+  'code.source': '代码',
+  'code.output': '输出',
+  'code.copySource': '复制代码',
+  'code.copyOutput': '复制输出',
+  'code.originalJson': '原始 JSON',
+  'code.running': '执行中…',
   'record.systemPromptMissing': '本次请求没有系统提示词',
   'record.toolsMissing': '本次请求没有工具',
   'record.systemPrompt': '系统提示词',
@@ -309,6 +315,12 @@ export const en: Record<TrajectoryKey, string> = {
   'record.outputJson': 'Result JSON',
   'record.thinking': 'Thinking',
   'record.wrapLines': 'Wrap lines',
+  'code.source': 'Code',
+  'code.output': 'Output',
+  'code.copySource': 'Copy code',
+  'code.copyOutput': 'Copy output',
+  'code.originalJson': 'Original JSON',
+  'code.running': 'Running…',
   'record.systemPromptMissing': 'No system prompt in this request',
   'record.toolsMissing': 'No tools in this request',
   'record.systemPrompt': 'System Prompt',

+ 116 - 0
packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx

@@ -0,0 +1,116 @@
+// @vitest-environment jsdom
+/** PTC source, output, and independent wrapping through the trajectory inspector. */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react'
+import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
+import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts'
+import { t } from './locale.client.ts'
+
+afterEach(() => {
+  cleanup()
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+})
+
+const SOURCE = 'const value = await tools.bash({ command: "pwd" })\nreturn value\n'
+const ARGS = JSON.stringify({ code: SOURCE, description: 'Read the working directory' })
+const CELL: TrajectoryCellProps = {
+  index: 1, kind: 'tool', text: `run_code · ${ARGS}`, callId: 'code-1',
+  inputDetail: ARGS, outputDetail: 'printed line\n42', result: 'printed line', timeSeconds: 0.5,
+}
+
+function Harness({ cell = CELL, getDefault = () => false, setDefault = () => {} }: {
+  cell?: TrajectoryCellProps
+  getDefault?: () => boolean
+  setDefault?: (value: boolean) => void
+}) {
+  return <TrajectoryTable
+    turns={[{ turn: 1, groups: [{ title: 'Step 1', cells: [cell] }] }]}
+    renderImages={() => null} t={t}
+    stringWrapping={{ label: 'Wrap lines', getDefault, setDefault }}
+    collapsedTurns={new Set()} onToggleTurn={() => {}}
+    collapsedAssistants={new Set()} onToggleAssistant={() => {}}
+  />
+}
+
+describe('PTC trajectory inspector', () => {
+  it('shows the description, numbered source, original arguments, and verbatim output', () => {
+    const view = render(<Harness />)
+    const ui = within(view.container)
+    fireEvent.click(ui.getByRole('row', { name: /run_code Read the working directory/ }))
+    expect(ui.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Summary', 'Code', 'Result', 'Schema', 'Timing'])
+    expect(ui.getByRole('tabpanel').textContent).toContain('Read the working directory')
+    expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd())
+    expect(ui.getByRole('tabpanel').textContent).toContain('printed line\n42')
+    fireEvent.click(ui.getByRole('button', { name: 'Code' }))
+    expect(ui.getByRole('tab', { name: 'Code' }).getAttribute('aria-selected')).toBe('true')
+    fireEvent.click(ui.getByRole('button', { name: 'Original JSON' }))
+    expect(ui.getByRole('tree', { name: 'parameters JSON' }).textContent).toContain('description:')
+    fireEvent.click(ui.getByRole('button', { name: 'Original JSON' }))
+    expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd())
+  })
+
+  it('copies exact source, raw JSON, and output with feedback', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    vi.stubGlobal('navigator', { clipboard: { writeText } })
+    const view = render(<Harness />)
+    const ui = within(view.container)
+    fireEvent.click(ui.getByRole('row', { name: /run_code Read/ }))
+    fireEvent.click(ui.getByRole('button', { name: 'Copy code' }))
+    await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(SOURCE) })
+    await waitFor(() => { expect(ui.getByRole('button', { name: 'Copied' })).toBeTruthy() })
+    fireEvent.click(ui.getByRole('button', { name: 'Copy output' }))
+    await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('printed line\n42') })
+    fireEvent.click(ui.getByRole('tab', { name: 'Code' }))
+    fireEvent.click(ui.getByRole('button', { name: 'Original JSON' }))
+    fireEvent.click(ui.getByRole('button', { name: 'Copy JSON' }))
+    await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(ARGS) })
+  })
+
+  it('samples the last wrapping choice on open without changing another open inspector', () => {
+    let preference = false
+    const props = { getDefault: () => preference, setDefault: (value: boolean) => { preference = value } }
+    const first = render(<Harness {...props} />)
+    const second = render(<Harness {...props} />)
+    const a = within(first.container)
+    const b = within(second.container)
+    fireEvent.click(a.getByRole('row', { name: /run_code Read/ }))
+    fireEvent.click(b.getByRole('row', { name: /run_code Read/ }))
+    fireEvent.click(a.getByRole('button', { name: 'Wrap lines' }))
+    expect(preference).toBe(true)
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false')
+    fireEvent.click(b.getByRole('tab', { name: 'Code' }))
+    expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    fireEvent.click(b.getByRole('button', { name: 'Wrap lines' }))
+    expect(preference).toBe(false)
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    fireEvent.click(a.getByRole('button', { name: 'Close details' }))
+    fireEvent.click(a.getByRole('row', { name: /run_code Read/ }))
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false')
+  })
+
+  it('shows pending execution and keeps a failed run’s captured output', () => {
+    const pending = { ...CELL, timeSeconds: null }
+    delete pending.result
+    delete pending.outputDetail
+    const view = render(<Harness cell={pending} />)
+    const ui = within(view.container)
+    fireEvent.click(ui.getByRole('row', { name: /run_code Read/ }))
+    expect(ui.getByRole('tabpanel').textContent).toContain('Running…')
+    view.rerender(<Harness cell={{ ...CELL, isError: true, outputDetail: 'Error: execution failed\nCaptured output:\nstep one' }} />)
+    expect(ui.getByRole('tabpanel').textContent).toContain('Failed')
+    expect(ui.getByRole('tabpanel').textContent).toContain('Error: execution failed\nCaptured output:\nstep one')
+  })
+
+  it('uses a tree for complete JSON output and generic input for malformed arguments', () => {
+    const view = render(<Harness cell={{ ...CELL, outputDetail: '{"passed":12,"failed":0}' }} />)
+    const ui = within(view.container)
+    fireEvent.click(ui.getByRole('row', { name: /run_code Read/ }))
+    expect(ui.getByRole('tree', { name: 'Result JSON' }).textContent).toContain('passed:12')
+    view.rerender(<Harness cell={{ ...CELL, inputDetail: '{"code":3}' }} />)
+    expect(ui.getByRole('tab', { name: 'Payload' })).toBeTruthy()
+    expect(ui.queryByRole('tab', { name: 'Code' })).toBeNull()
+    expect(ui.getByRole('tree', { name: 'Payload JSON' }).textContent).toContain('code:3')
+  })
+})

+ 60 - 0
packages/client/ui-trajectory/tests/code-program.client.spec.ts

@@ -0,0 +1,60 @@
+/** Recorded PTC argument validation and historical language selection. */
+import { describe, expect, it } from 'vitest'
+import { codeProgram } from '../src/client/code-program.ts'
+import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts'
+
+const CELL: TrajectoryCellProps = {
+  index: 1, kind: 'tool', text: 'run_code · {}', timeSeconds: 1,
+  inputDetail: JSON.stringify({ code: 'return 42\n', description: 'Compute the answer' }),
+}
+
+describe('recorded code programs', () => {
+  it('keeps the program and arguments verbatim', () => {
+    expect(codeProgram(CELL)).toEqual({
+      source: 'return 42\n', description: 'Compute the answer',
+      arguments: { code: 'return 42\n', description: 'Compute the answer' }, language: undefined,
+    })
+  })
+
+  it.each([
+    ['The program: the body of an async TypeScript function.', 'typescript'],
+    ['The program: the body of an async Python function.', 'python'],
+    ['A TypeScript or Python program.', undefined],
+    ['An executable program.', undefined],
+  ])('uses the recorded parameter description %s', (description, language) => {
+    const schemaDetail = JSON.stringify({ parameters: { properties: { code: { description } } } })
+    expect(codeProgram({ ...CELL, schemaDetail })?.language).toBe(language)
+  })
+
+  it.each([undefined, '{', '{}', 'null', '[]'])('keeps missing or unusable schemas plain: %s', (schemaDetail) => {
+    expect(codeProgram({ ...CELL, ...(schemaDetail === undefined ? {} : { schemaDetail }) })?.language).toBeUndefined()
+  })
+
+  it('uses the first nonempty source line when a description is unavailable', () => {
+    const source = '\n  const x = 1\nreturn x\n'
+    for (const description of [undefined, '', '   ']) {
+      expect(codeProgram({ ...CELL, inputDetail: JSON.stringify({ code: source, description }) }))
+        .toMatchObject({ source, description: 'const x = 1' })
+    }
+  })
+
+  it('retains empty source and unknown argument fields for JSON inspection', () => {
+    expect(codeProgram({ ...CELL, inputDetail: '{"code":"","extra":true}' }))
+      .toMatchObject({ source: '', description: '', arguments: { code: '', extra: true } })
+  })
+
+  it.each([undefined, '{', '[]', 'null', '42', '{}', '{"code":42}', '{"code":"x","description":[]}' ])(
+    'declines incomplete or unsupported arguments: %s', (inputDetail) => {
+      const cell = { ...CELL }
+      if (inputDetail === undefined) delete cell.inputDetail
+      else cell.inputDetail = inputDetail
+      expect(codeProgram(cell)).toBeUndefined()
+    },
+  )
+
+  it('does not specialize other tools or non-tool records', () => {
+    expect(codeProgram({ ...CELL, kind: 'message' })).toBeUndefined()
+    expect(codeProgram({ ...CELL, text: 'run_code_extra · {}' })).toBeUndefined()
+    expect(codeProgram({ ...CELL, kind: 'subtool' })?.source).toBe('return 42\n')
+  })
+})

+ 7 - 0
snapshots/web/ptc-round/code.expected.md

@@ -0,0 +1,7 @@
+- tabpanel "Code":
+  - text: Code typescript
+  - button "Wrap lines" [pressed]
+  - button "Original JSON"
+  - button "Copy code":
+    - img
+  - code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };"

+ 22 - 0
snapshots/web/ptc-round/trajectory.expected.md

@@ -0,0 +1,22 @@
+- heading "Code typescript Wrap lines Copy code" [level=3]:
+  - button "Code":
+    - text: Code
+    - img
+  - text: typescript
+  - button "Wrap lines"
+  - button "Copy code":
+    - img
+- code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };"
+- heading "Output Copy output" [level=3]:
+  - button "Output":
+    - text: Output
+    - img
+  - button "Copy output":
+    - img
+- text: "{"
+- tree "Result JSON":
+  - treeitem "bash:\"CODE_ROUND_OK\","
+  - 'treeitem "Expand readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}"':
+    - button "Expand"
+    - text: "readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}"
+- text: "}"